Repository navigation
feat(mcp): keep project MCP configs with resolved tokens out of git (#882) - #886
Conversation
…encent#882) A project-scope MCP config that carries a resolved ${VAR} sat untracked and unignored in the business repo, one `git add -A` from committing the token. After the reconcile writes such a file and git would track it, teamai lists its path in the clone's .git/info/exclude inside a marked block (resolved via `git rev-parse --git-path`, so linked worktrees and submodules work). The committed .gitignore is never touched; an ignored path or a config with no resolved value adds nothing; dry runs write nothing. Project-scope uninstall removes only teamai's block, and doctor reports such a file git would still commit. The hook sits after the appliers in reconcileMcpForConfig, outside desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with Tencent#880.
…Tencent#882) The plan now records whether the project's .git/info/exclude holds teamai's MCP config block (gitExcludeBlock). It counts toward isPlanEmpty, is listed in the summary and dry run, and gates the removal, so a plan whose only teamai leftover is the block removes it instead of reporting "Nothing to uninstall".
|
Findings
The PR description includes sufficient unit, e2e, and representative real-CLI testing. |
…cent#882) - uninstall keeps a repository's .git/info/exclude block while a config in it could not be parsed and still holds teamai servers, and warns - uninstall finds and removes the block in nested repositories holding an MCP config, across every worktree - the block opens at the last start marker, so an orphaned start never pairs with a later block's end and takes the member's lines - doctor counts only servers the ownership manifest records, not a member's own server under a team name
|
Findings
The four earlier findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing. |
…en clean (Tencent#882) A missing or unreadable managed-mcp.json made the MCP cleanup return early without reporting anything, so uninstall removed the block while .mcp.json still held the resolved token. Uninstall now inspects every path the block protects after the cleanup. The block goes only when each one is missing, or parses and holds none of the team's servers that need a resolved ${VAR}. Anything it cannot check keeps the block, with a warning naming the file. This replaces the leftInPlace report from the reconcile, which the check subsumes.
|
Findings
The four earlier findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing. |
…encent#882) Pull, doctor and uninstall each skipped a case they had not inspected and treated it as safe. Now: - pull lists a config in .git/info/exclude whether or not it delivered to it this run: a disabled or undetected tool's file, a team with automatic delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed write to another tool's config, and a lost ownership manifest (the resolved value found in the file) - doctor checks the same files, including one that does not parse, and counts a git error as a failure - git check-ignore failing inside a repository is no longer read as "not tracked": the path is excluded anyway, or teamai warns with git's error - uninstall also keeps the block while a file contains the value (8+ characters, not a path or the login name) of a variable still set in the environment, which finds a server since dropped from mcp.yaml
|
Findings
The previously reported findings are resolved in the current diff. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
…encent#882) Pull, doctor and uninstall still decided "clean" from the current team config in places. Now one function, resolvedValueEvidence, decides for all three: - a teamai-owned entry still in the file counts when its server has left mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot be read (doctor no longer skips that case) - targets include the built-in location of a tool the team dropped from toolPaths or moved - doctor names a file two tools share once - exclude updates take the existing acquireLock helper, re-read the file and write it atomically, so concurrent commands keep each other's paths - uninstall inspects every worktree of each repository owning a block, including a nested repository's linked worktrees, and applies the manifest rule per worktree - the kept-block warning names each file and why, such as the variable whose value matched
|
Findings
The previously reported findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing. |
…esolved tokens out of git # Conflicts: # src/doctor-delivery.ts # src/mcp-reconcile.ts
…s its lock (Tencent#882) After the 2.5 s wait for the exclude file's lock, updateExclude wrote without it, so two writers could drop each other's pattern and leave a plaintext MCP config committable. It now writes nothing and reports 'locked': pull warns that the file is not excluded yet and to run `teamai pull` again (doctor's exclude check keeps reporting it meanwhile), and uninstall keeps the block and warns.
… free of teamai's servers (Tencent#882) Uninstall judged a protected file clean from the current mcp.yaml, manifest and resolvable values, so with the manifest lost, the server gone from mcp.yaml and its value unset, a plaintext token looked like the member's own server and the exclusion went. It now fails closed and works per entry: a pattern goes only when its file is gone, holds no server, or holds none of teamai's servers with managed-mcp.json still there to say what teamai wrote. A kept entry is named with its file, why, and how to clean it by hand, since a rerun of uninstall finds no config after a full uninstall.
|
Findings
The other earlier findings are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
…proof, skip unlocked writes # Conflicts: # docs/usage-guide.md # docs/usage-guide.zh-CN.md
…lved value into it (Tencent#882) Pull listed the file in .git/info/exclude only after writing the plaintext, and a failed exclusion only warned, so the secret-bearing file stayed eligible for git add -A. The exclusion now comes first; when it cannot be established (exclude file or .git/info not writable, lock held past the wait, file already tracked, git error) the file is left as it was and the warning names the reason and the fix.
…p list and doctor (Tencent#882)
|
Findings
The earlier nested-repository linked-worktree issue and the other previously resolved findings remain fixed. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
…ed value Tencent#882's pre-write exclusion is now the single gate for a project MCP config git would commit. Tencent#880's separate tracked-file check (gitTracks in buildDesiredMcpContext, `withheld` in desiredMcpForTarget, its warning, mcp list lines and doctor note) is removed: a tracked file is one more way the exclusion fails, reported once with `git rm --cached <file>` and rotate. A dry run (mcp list, doctor) now also names a tracked file before any pull has listed it, mcp list reports a withheld file with an entry already installed, and doctor reports withheld servers without the pull --force text. A tracked file now gets no resolved value, declared secret or not.
…ready installed (Tencent#882) Backports Tencent#880's merge 0d9f7fa: a dry run (doctor, mcp list) names a tracked file before any pull has listed it, mcp list reports withheld for a server an earlier pull installed, and doctor's withheld note carries the exclusion's own fix instead of the pull --force advice.
…clude (Tencent#882) A tracked file needs `git rm --cached` whatever else is wrong, so ensureExcludedFromGit checks gitTracks before the writability check, on a pull and a dry run alike, and lists nothing for it.
…olds no resolved value (Tencent#882) A pull that lists a config in .git/info/exclude and then writes no value into it (it does not parse, a member's server holds the team's name, the write fails) removes the line it added. After a pull or `teamai mcp remove`, a line whose configs are proven clean in every worktree, by the proof uninstall uses (moved to mcp-reconcile.ts), is removed under the lock; one not proven clean stays. A config listed before its write is listed again after it, so a concurrent uninstall that dropped the line between the check and the write does not leave the value unprotected.
…re the pull rewrote it (Tencent#882) A pull whose manifest was lost before it ran recreates managed-mcp.json while reconciling, so the clean-file proof read the new record and took the exclude line out of a file still holding a teamai server that left mcp.yaml with its variable unset. The proof now uses this worktree's manifest as read before the reconcile.
A line this pull added and took back out, because it wrote no resolved value into the file, was reported as removed although the member never saw it added. Only removing a line an earlier run added stays at info.
|
Findings
The other previously fixed findings remain resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
…rst, backport
…holds a server (Tencent#882) A pull or `teamai mcp remove` judged every linked worktree's MCP config with today's definitions and values. Once a server's ${VAR} became a literal, and the value was no longer set, a pull in worktree A took worktree B's stale token-bearing entry for clean and removed the shared /.mcp.json line, so `git add -A` in B staged the token. These commands now release a line only when the current worktree's file passes the full proof and every other worktree's file is missing or holds no MCP server. `teamai uninstall` keeps its full proof in each worktree.
… install's credential file (Tencent#882)
…nd call the file's content a credential (Tencent#882)
|
Findings
The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
…amai too: a failed or partial one leaves what to keep out of git (Tencent#882)
|
Review Result
|
|
Please resolve the conflicts |
# Conflicts: # docs/designs/data-directory-layout.md # docs/usage-guide.md # docs/usage-guide.zh-CN.md # skill-data/setup/references/manage-admin.md # src/__tests__/doctor-mcp-delivery.test.ts # src/__tests__/mcp-cmd.test.ts # src/__tests__/mcp-git-exclude.test.ts # src/__tests__/mcp-reconcile.test.ts # src/doctor-delivery.ts # src/mcp-cmd.ts # src/mcp-git-exclude.ts # src/mcp-reconcile.ts
|
Findings
The previously reported findings remain resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
…lds, not by the new install's resolved: false (Tencent#882) install_mcp records the new entry before writing the config. When that write fails, the older entry, credential included, stays in the file while the record says resolved: false. localAgentCredentialFiles now trusts resolved: false only while the entry on disk has the recorded hash; otherwise it checks the entry itself. The doctor fixture that used a placeholder hash now records the hash an install writes.
…another tool owns under mcpServers (Tencent#882) recordedMcpFileEvidence merged a Copilot project file's bare servers with those under mcpServers by name, so Claude owning a nested jira read as owning a bare jira Copilot wrote with a token before it moved. Bare servers now count as owned only by a Copilot record: no other tool writes there.
|
Findings
The two findings from the September 30 review are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
…efault and a bare Copilot entry apart (Tencent#882) An HTTP team has no teamai.yaml history, so localAgentCredentialFiles never visited a built-in default teamai has since changed: a credential a local agent from before 57636a2 wrote to .codebuddy/mcp.json stayed committable. It now also reads EARLIER_BUILTIN_MCP_PROJECT (earlierMappedMcpTargets with history: false), in sync and doctor alike. It also judged a Copilot project file through installedMcpEntries, which merges bare servers with mcpServers by name, the keyed one winning. A tokenized bare entry beside a credential-free mcpServers entry of its name, both under one record, went unseen. Each entry is now judged on its own (mcpEntriesByPlacement, which recordedMcpFileEvidence uses too).
|
Findings
The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
|
Findings
The previously reported findings are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
|
Findings
The other previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
|
Findings
The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing. |
|
Findings
The previously reported ownership-loss issue is resolved: failed updates retain the prior manifest record and restore the prior config. The PR description documents sufficient unit, e2e, and representative real-CLI testing. Per instruction, I did not execute tests or PR code. |
listWorktrees read only `git worktree list --porcelain -z`, so on git without -z it returned [] to every caller. A pull or uninstall then judged the shared .git/info/exclude line from the current checkout alone and dropped /.mcp.json while a sibling worktree's .mcp.json still held a resolved value (Tencent#882/Tencent#886). Fall back to the newline-split list when -z fails. Liveness is unchanged: completeWorktreeList stays -z only and returns null there.
* fix(opencode): add team rules and instructions to V2 sessions through the plugin OpenCode V2 parses `instructions` and ignores it, so a V2 session got none of the team rules or the culture, claudemd and recall blocks. The teamai plugin's V2 setup now registers the session context and compaction hooks and adds the user instruction file and rules, then the nearest project's .opencode/teamai-context.md and .opencode/rules/**/*.md, sorted by path. V1 delivery through `instructions` is unchanged. (#993) * fix(opencode): write the user instruction file beside the Claude fallback for V2 OpenCode V1 reads ~/.claude/CLAUDE.md while ~/.config/opencode/AGENTS.md does not exist, so teamai wrote no OpenCode user file there. V2 reads neither that fallback nor `instructions`. Pull and HTTP prompts now write ~/.config/opencode/teamai-context.md for teamai's plugin in that case too, and still list it nowhere, so V1 gets no duplicate. (#993) * fix(init): set up only the --agent tools in single-repo init (#993) init . wrote .teamai/teamai.yaml (mode: self) before it read the existing project config. With the marker on disk and no config yet, that read ran the clone-time self-heal, which enabled every tool found in HOME, injected their hooks into the repo and saved them; init then merged --agent into that. Read the config once, before the marker is written, and use that snapshot for inheriting user scope, the enabledAgents union, the tool-roots carry-over and the mode-switch settlement. * fix(coauthor): keep the project-scope co-author setting out of shared settings (#993) In project scope only claude writes attribution, to the member-local .claude/settings.local.json; the rest of the Claude settings family is user-scope only, like Codex and Cursor. The next pull removes an attribution an earlier release wrote to a shared project settings file when it is exactly teamai's value and the co-author record lists the file, deleting only that member's text. * docs(coauthor): note a pre-fix shared value stays while no co-author choice exists (#993) * fix(doctor): check OpenCode V2 delivery from the teamai plugin doctor reported the team rules and instructions active on OpenCode V2 because `instructions` listed them, but V2 ignores that key. doctor now reads the major version from `opencode --version` (missing or unparsable counts as V1) and, on V2, checks that teamai's plugin in HOME is the one this build writes, since its context hook is what delivers them. V1 checks are unchanged. The usage guide describes V2 delivery. (#993) * fix(pull): probe live checkouts from recorded roots, not the worktree list (#993) A pull in a linked worktree of a --separate-git-dir repo or a submodule dropped the main checkout's delivery record and workspaces/<id>/, because git worktree list names the git directory there instead of the main checkout. Its next pull then overwrote edited rules without a word. Records and workspace directories now store their checkout root; a full pull keeps one while that root still has a .git, names the same git common dir, and (records) gives the same checkoutKey, so a re-created worktree still starts fresh (#807). Entries without a root (older CLI) are kept. listWorktrees reads --porcelain -z, skipping bare, prunable and common-dir entries, and returns [] when -z is unsupported instead of splitting lines. * fix(init): skip the clone-time self-heal for init in a single-repo clone (#993) A fresh clone whose committed .teamai/teamai.yaml says mode: self ran the self-heal in the preAction queue check and again in initSelfRepo's config read. Either one enabled every tool found in HOME before --agent could choose. init now loads with selfHeal: false in both places; every other command still self-heals. * fix(sync): adopt config entries that equal a team render, keep the member's (#993) An MCP server or a team hook entry teamai has no record of is teamai's only when it equals teamai's render of a team server or hook, today or at any revision in the team repo's history (one hook only). It is then adopted and handled as recorded. Any other one is the member's: kept and named by pull and doctor; a member's same-name MCP server means the team server is not written to that file. `pull --force` no longer overwrites a member's server (`teamai mcp inject --force` still does). This removes the second copy of each team hook a lost hook manifest caused in .claude/settings.local.json and .codex/hooks.json. * docs(sync): describe ownership of unrecorded MCP servers and hook entries (#993) * test(init): cover init . in a single-repo clone with no --agent and no terminal (#993) With the clone self-heal skipped for init, a fresh clone has no config when init runs. Pin that it takes the non-interactive default (the tools found in HOME) instead of prompting or stopping at "already initialized". * test(hooks): cover hook ownership beside the co-author setting in settings.local.json (#993) * fix(pull): prune a workspace directory without a root only when the worktree list names every checkout (#993) contribute, recall, viz, the MCP writers and the local agent create a checkout's workspaces/<id>/ before its first full pull writes root, so a removed worktree that never ran a full pull kept its directory forever (self-mode-worktrees-808). Such a directory now goes when git worktree list -z names every checkout (no non-bare entry is the common dir) and none has its id, as git prints it or realpath'd. It is kept with --separate-git-dir, in a submodule, on git < 2.36, and when it holds local-agent/. * fix(mcp): create a missing .git/info/ before excluding a resolved MCP config (#993) A repository created with an empty template has no .git/info/. The writability check ran `access(W_OK)` on that directory, got ENOENT, and reported it as "not writable", so every team MCP server holding a resolved value was withheld. Check the exclude file when it exists, otherwise its closest existing directory, and let the write create info/. Report "not writable" only for EACCES, EPERM and EROFS, and name the exclude file in the reason and the fix. Applies to pull, doctor, `mcp list` and local-agent installs, which share ensureExcludedFromGit. * fix(mcp): write CodeBuddy's user MCP servers to the file CodeBuddy reads (#993) CodeBuddy reads only the first user MCP file that exists of ~/.codebuddy/.mcp.json, ~/.codebuddy/mcp.json and ~/.codebuddy.json, and teamai always wrote the second, so team servers and the member's own hid each other. In user scope teamai now writes to that first existing file and creates ~/.codebuddy/.mcp.json only when none exists. Each CodeBuddy record in managed-mcp.json names its file; a pull moves teamai's own entries out of a file CodeBuddy no longer reads, uninstall and `teamai mcp remove` follow the record, and doctor and `teamai mcp list` name a shadowed file. * fix(local-agent): install CodeBuddy's user MCP servers in the file CodeBuddy reads (#993) An HTTP-backed team's install_mcp now writes CodeBuddy's user servers to the first existing file of CodeBuddy's lookup order, as a pull does, and records that file. A reinstall moves a server an earlier install left in a file CodeBuddy no longer reads; uninstall_mcp removes it from the recorded file. * fix(migrate): move the team clone's last-fetch stamp out of a superseded install (#993) A linked worktree whose old git-mode install another checkout superseded by switching to single-repo mode keeps the team knowledge in .teamai/ and moves only SUPERSEDED_ENTRIES to .teamai.bak. #964 added last-fetch.json beside the clone, which that list did not name, so it stayed in .teamai/ as an untracked file. The 808 fixture now writes the stamp a #964 release leaves, so the case fails without the fix whatever teamai the developer has installed. * fix(hooks): keep the managed-hooks index and the package lock out of the working tree (#993) getManagedHooksPath built <root>/.teamai/managed-hooks.json in project scope, so the Copilot hook index showed up untracked in every git project and self mode committed it with git add -A. It now resolves per checkout to <dataHome>/workspaces/<id>/managed-hooks.json, beside managed-mcp.json. legacyManagedHooksPath names the old file for the pre-#370 Codex import, the legacy hook sweep and uninstall. migrateLegacyManagedHooks moves its Copilot records (all records in self mode) before hooks are reconciled, removed or uninstalled, and deletes it once empty and untracked; doctor names a tracked one with the git rm --cached command. teamai.lock moves to the data home too (packageLockDir): an older .teamai/teamai.lock is moved on the next install or session-start read, doctor reads it where it is, and a .teamai/.gitignore holding only teamai.lock is removed outside self mode. Self mode's .teamai/.gitignore lists managed-hooks.json, also through its migration. * fix(pull): keep a rule or agent file teamai cannot prove its own (#993) A rule or agent destination that exists with no delivery record is teamai's only when its bytes equal teamai's render of that resource at some revision of the team repo's history (git blob ids, through ticket 04's matchesHistory). Otherwise it is the member's: pull neither writes nor deletes it, names it, and doctor lists it. This covers a checkout whose record key changed (a restore or copy gives .git a new inode), a role switch after that, and a member's own file with a team name. - DeliveryTarget.origin carries the proof; judgeCopy gains a 'member' verdict; isTeamaiCopy and describeMembersFile are the shared seam. - The rules stale sweep and the tombstone cleanup prove an unrecorded copy before deleting it. - A kept copy is worded as an edit when another checkout record lists its path (a record lost to a new key); no record is carried over. - A pull that keeps a member's file does not count as synced, so a plain pull delivers the team version once the file is gone. * fix(mcp): leave an older teamai's ~/.codebuddy/mcp.json that hides ~/.codebuddy.json (#993) Earlier releases always created ~/.codebuddy/mcp.json, so a member's servers in ~/.codebuddy.json stayed hidden. When that file holds only teamai's servers (recorded, or equal to a team render) and a later file of CodeBuddy's lookup order exists, a pull moves the servers there, deletes the file and says so once. A file that also holds a member's server or any other key stays chosen. * fix(remove): judge rule copies by record and team history in teamai remove (#993) teamai remove's rules refresh ran without the checkout's ledger, so it wrote over a member's own file at a team rule's path, and its explicit sweep deleted a member's own file named after the removed rule. It now opens the ledger, passes it to the refresh and names what it kept, and deletes a copy of the removed rule only when it is on record or its bytes are a version from the team history (the author's root copy keeps its placement record as proof). Docs say a personal rule the team never had is kept, and that a pull keeping a member's file syncs in full until it is gone. * fix(pull): keep a skill or doc teamai cannot prove its own (#993) A skill directory with no delivery record is teamai's only when every file in it is today's team file or a version of it from the team repo's history (SKILL.md also as pull repairs its frontmatter); one file of the member's makes the whole directory theirs. A file in the docs mirror at a team doc's path is teamai's only when it is a version of that doc. Anything else is kept and named, not written over or removed by the tombstone cleanup, and doctor lists it. isTeamaiSkillCopy is the ledger-free decision ticket 05 reuses. * docs(skill): keep the ownership sentence clear of the command check (#993) * fix(rules): delete a legacy .md in .cursor/rules only when it is teamai's (#993) Pull's stale sweep and pullItem deleted every .md in a .mdc tool's rules directory, and uninstall every one with a team rule's name, as a copy of the layout before .mdc. A member's own notes.md there went with them, though the usage guide promised it stays. Each now deletes one only when the checkout record lists it, it is a team version from the history verbatim, or it has a built-in rule's name (isLegacyLayoutCopy); any other is kept silently, since Cursor never reads it. teamai remove already applies the proof since 02b. * fix(coauthor): move a pre-fix attribution to settings.local.json when no choice exists (#993) With no co-author choice (the team dropped sharing.coAuthor and the member has none), the reconcile returned before settling shared project files, so an attribution an earlier teamai wrote to a tracked .claude/settings.json stayed there for git add -A to commit. The next pull now moves it to .claude/settings.local.json under the same guards (teamai's exact value, the record lists the file with "strip", only that key is edited), so the member's trailer is unchanged and the tracked file is clean. A value the member already set in settings.local.json is kept. Other Claude-family tools' shared files have no member-local counterpart and wait for a choice. A failed settle now keeps its record so the next pull retries. * fix(git): list sibling worktrees on git before 2.36 (#993) listWorktrees read only `git worktree list --porcelain -z`, so on git without -z it returned [] to every caller. A pull or uninstall then judged the shared .git/info/exclude line from the current checkout alone and dropped /.mcp.json while a sibling worktree's .mcp.json still held a resolved value (#882/#886). Fall back to the newline-split list when -z fails. Liveness is unchanged: completeWorktreeList stays -z only and returns null there. * fix(mcp): report an exclude directory teamai cannot create as not writable, not as a held lock (#993) acquireLock returns false when it cannot create the lock's directory, so updateFileLocked waited out 25 attempts and reported "another teamai command held <file> past the wait". With .git/info a regular file, MCP exclusion blamed a lock nobody held. updateFileLocked now creates the file's directory before taking the lock. EEXIST and ENOTDIR (a path on the way is not a directory) and EACCES, EPERM and EROFS throw a NotWritableError naming the file and the closest existing path above it, without waiting. ensureExcludedFromGit reports it as "<exclude> is not writable, as <path> is not a directory" and withholds the server as before. acquireLock's boolean contract is unchanged for its other callers. The e2e fixtures now use fresh paths per call, so a retried case rebuilds them instead of failing on the first try's. * docs(mcp): say an unrecorded entry under a name the team dropped is not adopted (#993) MCP ownership adopts an unrecorded entry only under a server name the team defines today, so a copy of a since-deleted team server stays as it is. Say so in the usage guide's ownership paragraph, in both languages. * fix(agents): prove an agent copy an older CLI rendered teamai's (#993) The agent ownership proof accepted a team version verbatim or rendered as pull renders it today, with today's aliases. Every member upgrading from 0.26.0 or earlier has no delivery record, and 0.26.0 rendered agents differently: the spec's model as written (no aliases), and before #830 Qoder, Qoder CN, ZCode and OMP took Claude's tool_extras and tclaude and tcodex only their base tool's. Such a copy read as the member's: never updated, named on every pull, and every pull a full sync. agentOrigin now also accepts the unresolved render and the pre-#830 extras render. * fix(pull): keep a member's file when the team deletes the doc at its path (#993) The docs mirror prune deleted every local file the team repo no longer has. At a path the team once had a doc, it now deletes the file only when it is a version of that doc from the team history; any other is kept and named, and doctor no longer lists it as stale. A local-only file at a path the team never had is pruned as before. * test(e2e): isolate HOME and the teamai binary for fixture git The git hooks teamai installs run `PATH="$HOME/.teamai/bin:$PATH" teamai hook-dispatch ...`. E2E fixtures ran git with the real HOME and PATH, so a fixture merge or checkout ran the developer's installed teamai against the real ~/.teamai (it rewrote ~/.teamai/bin/teamai and left fixture projects in ~/.teamai/projects). CI has no teamai, so local runs diverged from CI. A new e2e setup file gives each test file a sandboxed HOME (with a git identity), unsets XDG_CONFIG_HOME so XDG paths follow HOME, sets GIT_CONFIG_NOSYSTEM=1, and puts a `teamai` on PATH that runs the dist under test; all are restored afterwards. The remote suite in e2e.test.ts now clones the fixture team repo into the sandbox itself instead of relying on CI preparing the real ~/.teamai. The #808 single-repo case merges with the member's HOME, as a member's hook would run. * fix(pull): retry a resource a member's file held back even at the same revision (#993) A pull that keeps a member's file did not advance the revision, so the next pull would sync in full and deliver once the file was gone. When the checkout was already at that revision (pull --force, or the first pull after an older CLI's), the next pull still took the fast path and the team version never arrived, though the message says a pull brings it. Such a pull now resets the checkout record's revision (the shared revision in user scope), as a forced full sync does. * fix(doctor): report a .git/info that is a regular file as not writable on a dry run (#993) Doctor checks MCP exclusion with a dry run, which never reaches updateFileLocked and reads the unreadable exclude file as empty, so with .git/info a regular file it reported "not injected" and pointed at `teamai pull`. ensureExcludedFromGit's writability check now fails when the closest existing path above the exclude file is not a directory, so doctor's `MCP servers delivered to <tool>` gives the same not-writable reason and fix as pull. * fix(skills): deliver Codex skills into .agents/skills only over teamai's copy (#993) Codex reads the shared .agents/skills, which other tools and the member write to as well. The destination resolver took any existing copy there as teamai's and wrote the team skill over it, silently at init. It now takes an ownership predicate: pull asks judgeCopy against the checkout's record, doctor the team history alone, and teamai remove and uninstall isTeamaiSkillCopy. A copy that is not teamai's is left alone; the team skill goes to .codex/skills/<name>, and every full pull names the conflict. remove and uninstall delete only teamai's copy, in project and user scope. Source skills and the built-in stub keep the old rule for now. * test(doctor): give the older-copy fixtures a team history (#993) Two doctor-delivery-cli cases wrote a stale agent and Cursor rule copy by hand, with no delivery record and no team history: since #993 such a copy is the member's, so doctor reported it as "not teamai's". Each now commits the older version to the team repo first, so the copy is teamai's older render, as the case means. The Cursor case restores the team rule and its copy in a finally block, so a failure no longer breaks the cases after it. * fix(skills): delete only teamai's skill directories on remove, uninstall and pull (#993) teamai remove, uninstall, and pull's sweep of namespace-nested copies of an excluded skill deleted any directory at a team skill's name, including a member's own skill that pull keeps as not teamai's. remove and uninstall now delete a skill directory only when a file under it is on the checkout's record, or isTeamaiSkillCopy proves it (what pull writes today from the team source, or a team version by the history), and name each one they leave. remove judges before deleting the team source, so the local agent's history-less resource cache still removes its own installs. uninstall keeps trusting built-in names and names the local agent's manifest lists. The nested sweep deletes a copy only when removedCopyChanged says it is teamai's and unchanged. * test(uninstall): drop the commitTeamRepo import the merge duplicated (#993) * fix(source): deliver source skills where team skills go (#993) Source skills resolve through the team-skill seam: the agent-exclusion check, skillTargetForTool (Hermes home, OpenClaw workspace, Copilot home), then Codex's shared-destination rule with a real ownership proof against the source repo. Destinations outside the project are recorded as absolute paths inside the tool's home and shared per source across projects. An unrecorded existing copy that is no version of the source skill is the member's and is kept. Copies earlier releases left in the project's .hermes/skills and .openclaw/skills are reclaimed when unchanged, kept and named otherwise. * test(source): changed legacy source copies survive uninstall, remove and withdrawal (#993) * test(pull): pin delivery of an agent held at the team revision once its alias resolves (#993) A pull --force that holds an agent for its model leaves the checkout record at the team revision, so the next pull takes the fast path. The fast path's agent redeploy (#830) already writes the resolved model there, and a copy held before delivery is written as missing; no code change is needed. The test fails when that redeploy is disabled. * fix(uninstall): delete only teamai's rule and agent copies on uninstall, remove agents and pull (#993) uninstall and `teamai remove agents` deleted every rule or agent file at a team name, and pull deleted the namespaced copy beside the author's root copy on tools that do not flatten rules. Each now goes only on the checkout's record or a team version by the history; built-in names stay deletable, and a member's file is kept and named. * fix(packages): leave a tracked .teamai/teamai.lock in place and name it in doctor (#993) Moving a lock git tracks left a deletion in git status. A tracked (or unknown) lock is now copied to the data home instead, and doctor names it with git rm --cached, as it does the tracked hook index. * fix(docs): keep a local doc when the team history cannot be read (#993) The docs prune deleted a file the team no longer has when git could not read the team history. Without proof the file is the member's, as everywhere else. * test(skills): cover an unrecorded older team copy in .agents/skills updated in place for Codex (#993) * ci(e2e): drop the steps that prepared the real HOME the e2e sandbox replaces (#993) The e2e setup file gives every test file a sandbox HOME with its own git identity, and e2e.test.ts clones the fixture repo into it, so the git identity, teamai config and fixture cleanup steps act on a HOME nothing reads. * docs(ci): describe the e2e HOME sandbox in the local e2e setup (#993) * refactor(sync): build every not-teamai's line from one reason (#993) * test(agents): prove the removed and superseded copies teamai's by the team history (#993) * test(pull): give the docs prune fixtures a team history (#993) * fix(source): name the source's version, not a team version, for a member's skill at a source skill's path (#993) * refactor(local-agent): resolve a record's MCP file through recordedFileOf (#993) * fix(coauthor): report a recorded settings file that is not JSON as unreadable, naming it (#993) * test(mcp): wait for the session start's background pass before the git-info fixture cleans up (#993) hook-dispatch leaves a detached --bg-only pass running in the sandbox HOME after the foreground hook exits; it was still writing when afterAll removed the sandbox, so CI failed the suite with ENOTEMPTY. * test(opencode): keep the V1 hooks e2e off the npm registry (#993) With any plugin configured, OpenCode's first request for a directory waits for it to npm-install @opencode-ai/plugin into its config dir. On CI that fetch outlasted the 5 s session request. teamai's plugins import nothing from it, so the fixture records it as installed, and a failed session request now carries the server log. * fix(sync): name a kept copy of a removed resource as the member's or as an edit (#993) judgeRemoval replaces removedCopyChanged with three outcomes: remove, edited (changed since teamai delivered it, or on another checkout's record), and notTeamais (no record, no team version). Pull's tombstone cleanup and the rules sweep now say a member's own file is not teamai's instead of claiming the member changed a teamai copy. * fix(agents): delete a stale same-stem agent sibling only when it is teamai's (#993) removeStaleAgentSiblings deleted every same-stem file with another extension beside a rendered agent, a member's own included. It now goes through judgeRemoval with the agent's origin and the checkout record, and names a sibling it keeps. Built-in agents keep sweeping their own names. * test(agents): record the stale Kiro .md sibling as teamai's in the fixture (#993) * fix(docs): keep a member's entry whose type conflicts with a team doc (#993) The mirror replaced a local directory at a team doc file's path, or a local file at a team docs directory's path, and deleted the backup with its staging directory. membersDocs now treats such an entry as the member's unless the team history proves it teamai's (a file where the team now has a directory, or a directory whose every file is a team version), and copyDocs leaves a kept entry in place. A link is still replaced without touching its target. * test(skills): expect the not-teamai's line for a member's copy of a removed skill (#993) * fix(docs): keep a member's link at a team doc's path, without following it (#993) membersDocs skipped links, so the mirror still moved a member's link aside and deleted it with the staging directory (rest of the Codex P1). A link at a doc's path, or at an ancestor of one, is now the member's unless the team has the same link there: it is kept, named and never followed, and the docs under it are not delivered. * ci: re-run checks after an unrelated recall-attribution flake (#993) Node 22 on macOS failed on 9309e201 with one unhandled rejection from src/__tests__/recall-attribution.test.ts (a votes-lock write outliving the test's temp HOME); every test passed and #995 does not touch that code. The fork cannot re-run jobs, so this empty commit re-triggers CI. * ci: re-run the Codex PR review, cancelled at its time limit (#993) * fix(mcp): save the manifest when an adoption records a server it did not rewrite (#993) An unrecorded server equal to today's team render was adopted into the in-memory records, but with no file write the manifest was not saved, so a later team removal or uninstall found nothing owned and left the server installed. The reconcile now saves the manifest whenever this run changed it. * fix(mcp): preview a no-op MCP adoption on --dry-run, and cover uninstall (#993) A dry run now says it would record an unrecorded server that already holds the team's render, in .mcp.json-style files and in Codex config, and still writes nothing. The real-CLI cases add the dry run before the adoption and an adoption followed by teamai uninstall; both failed before b62e503b. Hook adoption needs no change: reconcileHooks saves its records whenever they differ from the previous ones, whether or not it wrote a file. * fix(docs): keep a member's directory where the team removed a doc file (#993) When the team deleted a doc file the member had replaced with a directory, the prune walked into that directory and deleted its files, which match no team version. A directory at a path where the team history had a doc file, holding anything that is not a team version, is now kept whole and named. * fix(mcp): remove an unrecorded copy of a server the team deleted (#993) With a lost manifest, a server an older release installed stayed for good once the team deleted it: only today's names were judged, and with no team server and nothing owned the reconcile returned early. Pull and uninstall now also remove an unrecorded entry, in .mcp.json-style files and Codex config, that equals a render of that server from the team history. A member's entry under that name stays. The early return now also requires a team history with no MCP server. * perf(sync): read the team's MCP history once per pull, in one git call (#993) historicalContents read each historical blob with its own git process (about 370 ms for 60 versions); it now reads them all with one git cat-file --batch (about 30 ms). The MCP reconcile reads that history once per run for every target, and only when a target file holds an unrecorded entry; with no team server, nothing recorded and no server in any target file it returns without reading it. A dry run names an unrecorded copy of a server the team deleted that a pull would remove. * fix(docs): keep a member's link where the team deleted a doc (#993) The prune unlinked any link at a removed team doc's path. A link there is now removed only when it is a version of that doc from the team history (git stores a link as a blob of its target); a link of the member's stays, and is never followed. A link at a path the team never had is pruned as before. * fix(coauthor): keep the record of a shared settings file that does not parse (#993) A pull that could not read a recorded shared settings file dropped its record, so once the member repaired the file no later pull removed the old attribution value. The record now stays until the file can be read. * fix(docs): keep any non-file entry of the member's where the team deleted a doc (#993) A link at a removed team doc's path goes only when it is a version of that doc from the team history; any other entry that is not a regular file stays and is named. Adds the real-CLI case for a member's link replacing a doc. * fix(sync): never adopt or write through a link at a delivered file's path (#993) isTeamaiCopy and the copy judgement followed links: a link at a rule or agent path whose target held an older team version was taken as teamai's, and the next pull wrote through it into the member's file. A link in place of a delivered file is now the member's (kept, named), a link there is never removed as teamai's copy, and a skill directory holding a link is not teamai's copy, since copying over it would write through it. * fix(docs): keep a member's directory of links where the team deleted a doc file, and do not call it stale (#993) A directory that replaced a former team doc file and held only links read as teamai's (links are not listed), so the prune removed it. Such a directory is now the member's whenever it holds a link. doctor also stops listing files inside a directory pull keeps whole as stale docs. * fix(hooks): recognize unrecorded team hooks in every format, on pull and uninstall (#993) Pull recovered a team hook entry whose record was lost only in Claude and Codex files. In Cursor, Copilot and ZCode files the old entry stayed as the member's and the new one was added beside it, so both ran. Each entry is now compared with the team's hooks rendered the way that format's reconciler writes them; one equal to exactly one hook is replaced or removed as teamai's. Uninstall did not judge unrecorded entries at all, so with the records lost it left custom team hooks installed, and missed a main-checkout Codex file holding only team hooks. It now judges every hook file against the team's hook history, gated to the project as pull renders them in a non-self project scope, and removes the entries equal to exactly one team hook. * fix(sync): treat any non-regular entry in a skill or docs directory as the member's (#993) A directory is teamai's copy only when every entry is a regular file that is a team version; a link or any other non-regular entry anywhere below it makes it the member's. Adds real-CLI cases for a skill directory holding a link and a docs directory of links where the team deleted a doc file. * fix(sync): keep a member's link at any delivered path (#993) A link at a delivered skill directory, rule or agent file, docs entry or source skill destination is the member's: teamai never creates links, so it is never adopted, replaced, written through or deleted, on pull, remove and uninstall alike. Pull and doctor name it: "Kept <path>: it is a link of yours, so teamai does not replace it. Remove the link to receive <name> from the team." This deliberately changes earlier behavior: source and team skills used to replace a leaf link in place of a skill directory with a copy, and the Codex duplicate cleanup deleted a configured leaf link. The source tests that asserted those now expect the link kept. * fix(agents): prove a tombstoned agent copy teamai's in a team-defined tool too (#993) For a tool teamai renders no agents for, the tombstone cleanup had no proof to check and removed any file with the removed agent's name. It now removes it only when it is a team agent's own bytes from the history; a member's file of that name stays. * fix(rules): never read through, write through or delete a member's link at a rule path (#993) The cleanup and rerender paths judged a rule copy by reading the file a link points at, so a link whose target held a team render was rewritten through or deleted: the "Already synced" rerender, the legacy rules directories (pull and uninstall), the reserved teamai-context name, the unselected-rule sweep, the moved nested copy, the flat OMP and Kiro copies, the older .md layout, and teamai remove rules. Each now treats a link as the member's and leaves it. The empty-directory sweep no longer deletes a directory whose only entries are links. * fix(agents): keep a member's link where an agent was delivered, on pull and teamai remove (#993) The inactive-namespace cleanup judged a copy by the file a link points at, and teamai remove agents deleted the author's root copy by its placement record alone. A link at either path is now the member's and stays. * fix(skills): keep a skill directory that holds a member's link, on pull, remove and uninstall (#993) A recorded skill whose file the member replaced with a link to the same bytes read as unchanged, so pull copied over the link and remove, uninstall and the tombstone and inactive-namespace sweeps deleted the directory with every link in it. A skill directory holding a link is now the member's on pull, named by the link, and kept by every sweep. teamai remove skills also judges a link at a built-in skill's name before the name. * fix(sync): keep an unrecorded file teamai cannot read where a removed resource was delivered (#993) judgeRemoval counted a file it could not read as absent and removed it. Without its bytes nothing proves it teamai's, so it is now kept as the member's. * fix(docs): never read through or delete inside a member's link in a deactivated docs namespace (#993) Withdrawing a namespace's docs read each copy through any link on its path and deleted what matched the team file. A namespace directory the member linked to a folder of their own lost its files, and a linked doc was deleted. A path through a link is now left alone. * fix(source): keep a source skill copy that holds a member's link, on pull and source remove (#993) A recorded destination was the source's whole, so pull copied over a link the member put inside it and source remove deleted the directory with it. Such a copy is now the member's: pull names the link and source remove keeps the directory on record. * fix(local-agent): keep a member's link where the local agent installs a skill or rule (#993) The local agent installs through pullItem with no delivery record, which skipped every ownership check: a rule was written through a link and a skill directory link was replaced. A link at the path, or inside the skill directory, is now kept and named. * fix(sync): deliver team and source skills without the links in them (#993) Copying a skill recreated each link in its source as a link in the member's tool directory, which then read as the member's own, and let a skill plant a link to any target. Delivery now skips a link in a team or source skill, without following it, and names each one once: "Skipped <link> in <skill>: teamai does not deliver links." * fix(docs): keep a member's link in the docs mirror at a path the team never had (#993) This deliberately changes the mirror prune: it used to unlink any link at a path the team repo's history never had, as a local leftover. teamai never creates a link there, so such a link is the member's: pull now keeps it and names it, and doctor no longer reports it as a stale doc. A link matching a team link once at a removed doc's path is still pruned. The docs and doctor tests that expected the unlink now expect it kept. * docs(designs): state that teamai never follows, replaces or deletes a member's link (#993) * fix(uninstall): remove only teamai's docs from the docs mirror, and keep the member's (#993) Uninstall deleted the whole docs directory, so a file pull had kept as the member's, a directory or a link of theirs went with it. It now removes a file or link only when it is a version of that doc from the team repo's history, names everything else, and removes a directory only once it is empty. While the history cannot be read, the mirror stays whole. A mirror inside the data home that keeps something survives the removal of that home with it. * fix(mcp): write a linked MCP config through its link, and keep its target out of git (#993) A member's MCP config can be a symlink into a dotfiles repository. The atomic writes replaced the link with a regular file. They now write at the file the link points to, rollbacks included, and a linked CodeBuddy ~/.codebuddy/mcp.json is kept rather than deleted. A resolved value then lands in the link's target, so the git checks judge that file in its own repository: a target that repository tracks gets no value, and an untracked one is listed in that repository's info/exclude, which pull and uninstall now find to take the line out again. * fix(docs): name a kept link at a removed doc's path as before, and only a never-team path's link as such (#993) Goes with keeping a member's link at a path the team never had: a link at a removed doc's path keeps the line that says the team removed it. * test(skills): expect pull to name the member's link inside a skill directory (#993) * fix(opencode): keep a member's linked opencode.json and openclaw.json as links (#993) The OpenCode instructions writer and the OpenClaw hook entry writer replaced a symlinked config with a regular file, and uninstall deleted a linked .opencode/opencode.json left holding only its $schema. Both now write at the link's target, and a link is never deleted. * fix(rules): never delete a linked CLAUDE.md or AGENTS.md when its teamai section goes (#993) Removing teamai's section from an instruction file teamai created deleted the file once nothing else was left, a member's link to it included. A link stays; its target is emptied instead. * fix(skills): push no links to the team repo, and name why remove and uninstall keep a skill directory (#993) A push copied a link inside the member's skill into the team repo, so every member then received a skill teamai will not deliver; push now skips each link and names it. remove and uninstall now say a kept skill directory is a link of the member's, or holds one, instead of claiming it has no delivery record. * fix(source): keep reading a record into a removed OpenClaw workspace, and never another repository's history (#993) A source record of a copy in an OpenClaw workspace outside ~/.openclaw was rejected once that workspace was gone, so every pull and source remove of that source failed. The configured workspace now counts as a tool home even when its directory is missing. A source cache without its own .git no longer lets git read an enclosing repository's history (a dotfiles HOME): only today's source proves a copy then. * fix(source): leave a copy in a tool home that moved, instead of rejecting the record (#993) Configuring OpenClaw to another workspace made the record of a copy in the old one invalid, so every pull and source remove of that source failed. The record now notes the tool home of each absolute destination; one whose home moved is dropped from the record and its copy left as it is, while an absolute path the record cannot account for is still rejected. * fix(docs): keep the member's hidden files on uninstall, and match a link only to a team link (#993) uninstall did not count hidden entries in the docs mirror as kept, so with nothing else kept it deleted the data home around them. They are now named and keep the mirror. A link in the mirror is now teamai's only when the team history had a link with that target there, not a file holding the same text (the history now carries each version's git mode). * fix(skills): keep a delivered skill directory that holds a file the member added (#993) A skill directory with any file on record was taken as teamai's whole, so remove, uninstall and the tombstone cleanup deleted a file the member had added beside teamai's. A recorded directory now goes only when every file in it is on record or a team version; otherwise it is kept whole and named. Source skill removal applies the same rule against the source's files and history. * fix(skills): remove and uninstall take only teamai's files from a skill directory (#993) When a delivered skill directory holds a file the member added, remove and uninstall now delete only the files on record or matching a team version, name each file they keep, and leave the directory while anything is left in it. uninstall plans the exact files, so its dry run shows them. * fix(source): keep a member's file at the path of a link the source skill has (#993) Delivery never copies a link from a source skill, but removal counted any history at that path as the source's, the link included, so a regular file the member put there was deleted with the skill. Only a regular file the source has, or had, is the source's now. * fix(mcp): find teamai's CodeBuddy servers in every user MCP file, and treat a linked lookup path as its target (#993) A server an earlier teamai left in a CodeBuddy user MCP file that CodeBuddy no longer reads stayed there once its record was lost: pull and uninstall looked only at the file CodeBuddy reads, and returned early when that file was empty. They now also look at the other lookup files that exist and remove an unrecorded entry equal to a current or historical team render; a member's entry stays. Lookup files are compared by real path. With ~/.codebuddy/.mcp.json a symlink to ~/.codebuddy/mcp.json, a pull recorded the server at the link and then removed it from its target, the same file. With ~/.codebuddy.json a symlink to ~/.codebuddy/mcp.json, the upgrade move emptied and deleted the file the link points to. * fix(sync): prove a CONTRIBUTORS file like any other, and a file only by a file the team had (#993) A skill's CONTRIBUTORS file counted as teamai's by its name alone, so remove and uninstall deleted the member's own one, and it never made a directory the member's. It now needs the same proof as any file. A link in the team history no longer proves a regular file holding the same bytes, and a version that changed only its mode is kept as its own version. * fix(skills): keep a delivered skill when the team or the source adds a file where the member has one (#993) A file the member added to a recorded team or source skill was overwritten once the team, or the source, delivered a file at that path. Pull now keeps such a copy whole and names it, as an edited one, unless the file is a version from the history. A source removal keeps a copy holding a file of the member's only when the source's own files prove the copy came from it: a copy from a replaced repository, or one an in-repo alias placed, is left to the record, as before. * fix(docs): keep a file of the member's at a path the team never had (#993) The docs mirror pruned any local file at a path the team history never had, so a member's own notes there were deleted on the next pull. Having no team version is no proof that teamai put a file there: the prune now removes only teamai's copies of docs the team removed, and leaves the member's own files without a word. doctor stops calling them stale, and the usage guide no longer says local drafts are removed. Earlier tests that expected such files pruned now use team docs the team removed. * docs(designs): the docs mirror prunes only teamai's copies of removed docs (#993) * fix(rules): remove a rule copy that holds what pull writes today (#993) `remove rules` deleted a tool's copy only on the checkout's record or the team history. A checkout without history and without a record, such as a resource cache, left teamai's own copy behind. The copy that holds today's render of the team rule, or the rule verbatim, is now teamai's too, read before the team file goes, as `remove skills` already does. * fix(source): keep a source skill copy holding the member's file, on record (#993) A copy pull kept whole because the source added a file where the member had one was left off `installedSkills`, so `source remove` forgot it and dropped the manifest. It stays on record now. Removal then tells the source's files from the member's by their bytes, not their path, and a file at a path the skill never had keeps the directory even when no file of the source is left in it. * test(local-agent): uninstall_rule removes the installed copy and keeps the member's (#993) * fix(mcp): tell another tool's server by the file's real path (#993) An entry another tool's record claims in the same file is left to that tool. The file was compared by name, so a tool whose MCP path links to another's file adopted that tool's server once its own record was lost, and a later removal for it would take the server from the shared file. It is compared by real path now, as the rest of MCP reconciliation does. * fix(source): keep a source skill copy whose every file the member edited (#993) Removal kept a copy holding an edited file only when another file still matched the source. A copy whose every file the member edited, such as a single SKILL.md, was deleted by `source remove` or by a pull after the source withdrew the skill. When the source history has the skill, a file that matches no source version keeps the copy. * fix(ownership): keep a member's entry of the other type at a delivered path (#993) A file where a team skill directory goes, or a directory where a rule or agent file goes, was not judged the member's. Delivery over it failed, yet the pull could record the team revision as synced, so once the member removed the entry a plain pull delivered nothing until the team changed or the member ran `pull --force`. Such an entry is now kept and named, and the next pull after it is gone delivers the team version. * fix(skills): keep a member's file when removing a skill with a built-in's name (#993) `teamai remove skills <name>` deleted the whole directory when the name is one the CLI once shipped (such as team-wiki-codebase), so a file the member added beside it was lost. It now removes the files the team history proves teamai's and the files a release packaged there, as uninstall does, and keeps and names a directory left holding anything else. * fix(skills): keep a delivered skill when the team adds a file where the member has a directory (#993) When the team added a file to a delivered skill at a path where the member had a directory, or a directory where the member had a file, the copy failed inside pull and the pull still advanced to the team revision, leaving the skill stale. Such an entry is the member's now: the skill is kept whole and named, as when the team adds a file where the member has one. * fix(local-agent): tell CodeBuddy's user MCP file by its real path (#993) With `~/.codebuddy/.mcp.json` linked to `mcp.json`, a record an older agent wrote without a file resolved to `mcp.json`, while the install target was `.mcp.json`. The record was dropped as another file's, so reinstalling the same server failed as "not managed by teamai". Records are matched to the target by real path now, as MCP reconciliation does. * fix(skills): deliver a skill around a member's entry of the other type, and retry failed copies (#993) A member's directory where the team adds a file to a delivered skill, or a file where it adds a directory, is the member's: it is kept and named, the files it covers stay undelivered, and the rest of the skill is delivered. The pull is not counted as synced, so the next one is a full sync that delivers those files once the entry is gone. A skill, rule or agent copy that fails with an error no longer counts as synced either: the revision stays, and the next pull retries it. * fix(source): keep a source skill copy when the source adds a directory where the member has a file (#993) A file of the member's where the source now has a directory was missed by the collision check, so the copy failed on the conflict instead of keeping the member's copy. An entry of the other type at a source path, or at one of its parents, is the member's now, also when the source cache has no history. * fix(uninstall): keep OpenCode's user plugin on a project uninstall (#993) The plugin in the user's OpenCode config now also carries the team rules and instructions for V2, for every project and the user scope. A project-scope uninstall removed it, which ended delivery for every other project. It keeps it now and names it, as it does for the other global adapters; a user-scope uninstall still removes it, and so does `teamai hooks remove`. * fix(skills): keep a skill directory holding a git repository of the member's (#993) The ownership check reads a skill directory's files the way delivery lists them, which skips `.git`. A recorded skill whose files matched was then deleted whole by `teamai remove`, `uninstall` or a source removal, taking a repository the member made in it, with its local commits. A directory holding a `.git` is never deleted whole now; only teamai's files in it go. * fix(local-agent): keep a CodeBuddy server copy the member edited when moving it (#993) When CodeBuddy's preferred user MCP file changed, reinstalling a server moved it there and deleted the copy in the old file without checking it. A copy the member edited since teamai installed it is kept now, and named. * fix(local-agent): keep a CodeBuddy server's record until its move is written (#993) Moving a server to the user MCP file CodeBuddy now reads recorded it in the new file before writing it there. When that write failed, the record pointed at the new file while the server stayed in the old one, so uninstall left it installed. The old file's record is existing ownership now: it stays until the new file is written, as for a server already recorded in the target file. * fix(uninstall): record a project's OpenCode exclusion when its plugins are kept (#993) A project-scope `uninstall --agent opencode` keeps the user plugin directory, so with only a server-pushed agent-hook plugin there the plan was empty: it said "Nothing to uninstall" and never excluded OpenCode, whose global hooks stayed active for the project. OpenCode now takes the exclusion-only path, like the other tools with global channels, and the kept agent-hook plugins are named with the other retained adapters. * fix(docs): keep a member's doc that has the bytes of a team doc that is a link (#993) A regular file of the member's at the path of a team doc that is a link was taken for teamai's copy when its bytes matched the link's target, and the mirror replaced it with the link. Equal bytes now prove a copy only of a team file; such a doc is the member's unless the team history has that file. * fix(mcp): judge each CodeBuddy lookup file with the tools that write it (#993) Cleaning CodeBuddy's other user MCP files reused the ownership check built for the file it reads, which knew of no other tool writing there. With `~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json`, Claude's copy of a team server matched CodeBuddy's render and was deleted through the link. Each file is now judged with the records of every tool whose MCP file it is. * fix(docs): judge a deactivated namespace's doc by its type as well as its bytes (#993) When a docs namespace stops being active here, its copies that are teamai's are removed. A member's regular file whose bytes equal the target of a team doc that is a link was taken for teamai's, through the link and through a history check that also matched link versions, and deleted. A file is teamai's only as a team file now, today's or one from the history, as on every other docs path that deletes or replaces (prune, copy, uninstall). * fix(mcp): count another tool's servers in a shared file even when it is not detected (#993) Whose entries a config file holds was judged only with the tools detected on this run. With `~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json` and Claude no longer installed, a CodeBuddy pull took Claude's identically rendered server for its own and deleted it through the link, leaving Claude's record stale. Every tool that maps a file counts now, detected or not: detection decides where teamai writes, never what it may delete. Doctor judges the same way. * fix(mcp): restore a file two tools write through linked paths once, to its first state (#993) Configs written during an MCP sync are restored when ownership cannot be saved. Snapshots were keyed by path, so a file two tools reach through linked paths got one per path and the later one, taken after the first tool's write, was restored last: the server stayed without its record. One snapshot per real file now, taken before its first write. * fix(coauthor): leave a settings file that does not parse as it is (#993) Project scope writes the co-author setting to `.claude/settings.local.json`, the member's own file. One that did not parse was read as empty, so a pull replaced the whole file with teamai's `attribution` block and the member's settings were lost. A Claude or Cursor file that is not a JSON object is now left untouched and reported as skipped, and the setting is written on the first pull after the member repairs it. * fix(hooks): leave a hook settings file that does not parse as it is (#993) Every hook writer read its settings file as empty when it did not parse, so a pull or a session-start pass while the member was mid-edit of, say, their HOME `~/.claude/settings.json` replaced the whole file with teamai's hooks. The Claude, Cursor, Copilot, Codex and ZCode writers, and the agent-hook install and removal, now leave such a file byte-identical and say which file and why; nothing is recorded, and the first pull after the repair writes it. * fix(docs): never walk a docs mirror that is a link of the member's (#993) When `sharing.docs.localDir` itself was a link to a personal directory, uninstall walked it and deleted the files behind it that matched a team doc from the history, outside the mirror. A mirror root that is a link is now the member's on every path: pull copies, prunes and withdraws nothing through it and names it, doctor reports it, and uninstall leaves it and what it points at. * fix(uninstall): keep the record of hooks a settings file that does not parse still holds (#993) Uninstall left out a hook settings file that did not parse, then deleted the data directory holding the record of its hooks; once the member repaired the file, teamai's hooks ran there with no record left to remove them by. Such a file is now tried, named and left as it is, and the data directory stays, so uninstall run again after the repair removes them. `source remove-http` and uninstall keep the record of an HTTP agent hook they could not remove, and the local agent's directory with it, instead of clearing the whole manifest. Pull also names a docs mirror that is a link before counting or previewing its files, so it never walks it, on a dry run either. * fix(uninstall): report an uninstall that left hooks in place as incomplete (#993) When a hook settings file could not be cleaned, uninstall kept the data directory but still said "teamai uninstalled" and exited 0, and a targeted uninstall of the last tool did not record the tool as excluded, so the hook left in place could sync its resources back. Hooks left in place now make the uninstall incomplete, as an OpenCode entry it could not remove does: exit code 1, the files named, and the tool excluded. The message for a settings file that does not parse no longer points at `teamai pull`. * fix(uninstall): stop hooks an incomplete uninstall left in place from syncing back (#993) A full uninstall that could not remove a tool's hooks kept the data directory with every tool still enabled, so the hook left in place ran a session-start pull that restored what had just been removed. Each tool whose hooks remain is now excluded, and the session-start pull of an excluded tool's hook does nothing, as its instruction and HTTP handlers already did, until the uninstall is run again after the repair. * fix(local-agent): keep removed HTTP sources disabled (#993) * fix(local-agent): serialize HTTP sync and source removal (#993) * docs(http): describe skipped concurrent hook syncs (#993) * fix(uninstall): remove the HTTP source under its lock before teardown (#993) A full user-scope uninstall removed agent hooks, plugins and ~/.teamai without the local-agent lock, so a hook sync paused after loading its config could reinstall resources once uninstall reported success. Uninstall now runs the remove-http shutdown first: it waits up to 30 seconds for the lock, disables the source before teardown, and exits 1 without removing anything when the lock cannot be taken. * fix(local-agent): let a server-pushed uninstall hold its sync's lock (#993) A sync holds the HTTP source lock while it runs an uninstall_teamai command, so the uninstall it spawns waited for that lock until the hook's 15-second budget killed the sync, unacknowledged and with teamai still installed. The sync now passes its pid to the command; a child whose parent holds the lock runs under it without acquiring or releasing it. * docs: point the kept-edit links in sharing at member-guide's Auto-sync The Auto-sync section moved to member-guide in the docs reorganization, so the in-page anchors #993 added no longer resolve.
…915) (#1000) * test(agents): record the stale Kiro .md sibling as teamai's in the fixture (#993) * fix(docs): keep a member's entry whose type conflicts with a team doc (#993) The mirror replaced a local directory at a team doc file's path, or a local file at a team docs directory's path, and deleted the backup with its staging directory. membersDocs now treats such an entry as the member's unless the team history proves it teamai's (a file where the team now has a directory, or a directory whose every file is a team version), and copyDocs leaves a kept entry in place. A link is still replaced without touching its target. * test(skills): expect the not-teamai's line for a member's copy of a removed skill (#993) * refactor(git-exclude): one module owns git exclude blocks, mcp-exclude moves onto it (#915) src/git-exclude.ts owns info/exclude files: sync (replace), ensure (add-only gate with a per-path result), remove (owner or all, with a keep predicate) and report. Owners are named [a-z0-9/_%-] with per-owner markers; mcp-exclude keeps #886's markers, trimmed parsing and its already-ignored rule, and ensureExcludedFromGit maps ensure's results to GitExclusion unchanged. Lines route to the repository a path lands in (submodule, nested clone, symlink target), use the on-disk spelling, NFC only with core.precomposeunicode, and refuse line breaks and trailing spaces. Each owner's exclude files live in a caller-supplied record; stateHomeRecord keeps them in <stateHome>/git-exclude.json. git children drop the repository variables a hook exports. The re-including rule's source is now resolved from the toplevel, where git names it, not from the file's directory: a rule in .claude/.gitignore was named as .claude/rules/.claude/.gitignore. * fix(docs): keep a member's link at a team doc's path, without following it (#993) membersDocs skipped links, so the mirror still moved a member's link aside and deleted it with the staging directory (rest of the Codex P1). A link at a doc's path, or at an ancestor of one, is now the member's unless the team has the same link there: it is kept, named and never followed, and the docs under it are not delivered. * ci: re-run checks after an unrelated recall-attribution flake (#993) Node 22 on macOS failed on 9309e201 with one unhandled rejection from src/__tests__/recall-attribution.test.ts (a votes-lock write outliving the test's temp HOME); every test passed and #995 does not touch that code. The fork cannot re-run jobs, so this empty commit re-triggers CI. * fix(git-exclude): never take an unreadable exclude file for empty (#915) updateFileLocked read through readFileSafe, which turns every read error into empty content: an info/exclude that exists but cannot be read was rewritten with only teamai's block, dropping the member's lines, and the ensure that gated a resolved MCP value reported success. A read failure other than ENOENT/ENOTDIR now throws NotReadableError and writes nothing. ensure returns notReadable {path, error, reason, fix}; sync and remove report write kind notReadable and keep the file in the owner's record; ensureExcludedFromGit maps it to failed, so the secret is withheld. A missing exclude file is still created. * feat(git-exclude): keep delivered skills out of git behind sharing.gitExclude (#915) sharing.gitExclude.enabled (default false; init writes true into a new git-mode or single-repo teamai.yaml) and the partition override gitExcludeEnabled resolve as override, team, false. pull() carries one DeliveryRecorder for the project scope. Writers report landed paths per writer id and say whether they succeeded or failed; the skills handler is the first (through the pull's ledger, so the local agent's ledger-less calls report nothing). After pullSources, still under the partition sync lock and skipped for a contended scope, pull merges the reports into the checkout record's gitExcludePaths (fast path add-only; full sync replaces writer by writer, a failed writer keeps its previous entries still on disk) and syncs teamai's delivered block in the clone's info/exclude, or removes only that block when the flag is off. The list is kept whatever the flag; awaitingFullSync and the complete-branch rebuild carry it, and a record without it misses the fast path, so the first pull after upgrading from 0.26.0 is a full sync. The delivered owner's exclude files are recorded in the partition state (gitExcludeFiles). * feat(git-exclude): keep everything a pull delivers out of git (#915) Every writer of a project-scope pull now reports what it wrote, or confirmed as teamai's, to the delivery recorder, and says whether it delivered all it meant to: - rules, one file per line (namespace subdirectories, flattened names, Copilot instructions), also from the fast path's rewrites; - agents, plus a copy a tool holds for its model while the record still matches it; - the built-in teamai skill and the teamai-recall rule and agent; - the owned teamai-context files that hold teamai's blocks; - the main checkout's .claude/settings.local.json while its hook manifest records team hooks there, and Copilot's .github/hooks/teamai.json while it holds teamai's entries, read from disk so an unresolved team hook set or Copilot's built-ins-installed skip still lists them; - .claude/settings.local.json while the co-author record says teamai wrote the empty trailer and the file still holds it; - source skills this pull landed, never the manifest's entries. No configured source is a successful empty result (the copies stay on disk, visible); a source left as it was keeps its previous lines. A copy pull keeps for the member's edit is never reported, so it leaves the block. Collisions and failed writes mark their writer failed. The acceptance fixture (roles, a project, a source, recall, team hooks, MCP with and without a resolved value, Copilot plus six agents, a Codex copy in .agents/skills, a tracked SKILL.md with a new team file) checks git status against an allowlist of paths later tickets move, after init, pull and a session start, with the member's files visible and addable. * ci: re-run the Codex PR review, cancelled at its time limit (#993) * fix(mcp): save the manifest when an adoption records a server it did not rewrite (#993) An unrecorded server equal to today's team render was adopted into the in-memory records, but with no file write the manifest was not saved, so a later team removal or uninstall found nothing owned and left the server installed. The reconcile now saves the manifest whenever this run changed it. * fix(mcp): preview a no-op MCP adoption on --dry-run, and cover uninstall (#993) A dry run now says it would record an unrecorded server that already holds the team's render, in .mcp.json-style files and in Codex config, and still writes nothing. The real-CLI cases add the dry run before the adoption and an adoption followed by teamai uninstall; both failed before b62e503b. Hook adoption needs no change: reconcileHooks saves its records whenever they differ from the previous ones, whether or not it wrote a file. * feat(git-exclude): give Copilot the team instructions from a file teamai owns (#915) With sharing.gitExclude on, Copilot's project culture, shared-instructions and recall blocks go to .github/instructions/teamai-context.instructions.md with applyTo: "**", which the delivered git exclude block lists. The next pull strips teamai's blocks from .github/copilot-instructions.md and deletes it only when teamai created it and git does not track it. Turning the flag off moves the blocks back on the next pull. Doctor checks the new file through the existing instruction checks; the usage guide names the Copilot surfaces that read no .github/instructions file and the chat.includeApplyingInstructions dependency. * fix(docs): keep a member's directory where the team removed a doc file (#993) When the team deleted a doc file the member had replaced with a directory, the prune walked into that directory and deleted its files, which match no team version. A directory at a path where the team history had a doc file, holding anything that is not a team version, is now kept whole and named. * feat(git-exclude): keep tracked copies a removal would delete, and sweep Codex's shared skills (#915) When a delivered copy stops being delivered (role or project switch, a root skill once roles are set, a team tombstone or removed rule, a source dropping a skill), the removal pass now keeps a path the checkout's index tracks and names it once per pull with the git rm -r hint, so a later pass that proves the copy teamai's still leaves it. The inactive-namespace, stale-skill and tombstone sweeps also visit Codex's .agents/skills/<name>, deleting a copy only when judgeRemoval returns remove and naming an edited or member copy. * feat(git-exclude): keep self-mode team hooks out of the tracked settings (#915) In a single-repo team with sharing.gitExclude on, Claude Code's team hooks go to each checkout's own .claude/settings.local.json, which the delivered block lists, and the committed .claude/settings.json keeps only the built-ins, so a fresh clone still has them and a pull leaves git status clean. The per-checkout hook index records the team hooks of the one file that holds them. Turned off, the next pull moves them back into the tracked settings. init . syncs the delivered block after its own writers, since it ends without a pull. hooks remove and uninstall also clear settings.local.json. * fix(mcp): remove an unrecorded copy of a server the team deleted (#993) With a lost manifest, a server an older release installed stayed for good once the team deleted it: only today's names were judged, and with no team server and nothing owned the reconcile returned early. Pull and uninstall now also remove an unrecorded entry, in .mcp.json-style files and Codex config, that equals a render of that server from the team history. A member's entry under that name stays. The early return now also requires a team history with no MCP server. * feat(git-exclude): one delivered block for every live checkout, and other repositories' paths where git reads them (#915) A pull now lists in teamai's delivered git exclude blocks the union of the lists of the project's live checkouts (liveCheckoutRecords, now exported), so a pull in one worktree no longer drops another worktree's lines, also in a --separate-git-dir repo and in a project that is a submodule. A removed or pruned worktree's lines go on the next pull, fast path included. A delivered path that holds a file of the member's in another live checkout (not in that checkout's list) gets no line, since the shared line would hide that file too; pull names it. A checkout with no list yet has no such files, and the member's own .claude/settings.local.json in a linked worktree is never one. A path tracked in one checkout and untracked in another stays listed. Paths that land in another repository (a tool folder that is a submodule or a nested clone, a tool home under version control) go to that repository's exclude file, in a delivered/<id> block named after the partition, so the superproject stops showing the submodule modified and projects sharing a tool home each manage only their own block. Turned off, both blocks go. * perf(sync): read the team's MCP history once per pull, in one git call (#993) historicalContents read each historical blob with its own git process (about 370 ms for 60 versions); it now reads them all with one git cat-file --batch (about 30 ms). The MCP reconcile reads that history once per run for every target, and only when a target file holds an unrecorded entry; with no team server, nothing recorded and no server in any target file it returns without reading it. A dry run names an unrecorded copy of a server the team deleted that a pull would remove. * fix(docs): keep a member's link where the team deleted a doc (#993) The prune unlinked any link at a removed team doc's path. A link there is now removed only when it is a version of that doc from the team history (git stores a link as a blob of its target); a link of the member's stays, and is never followed. A link at a path the team never had is pruned as before. * fix(coauthor): keep the record of a shared settings file that does not parse (#993) A pull that could not read a recorded shared settings file dropped its record, so once the member repaired the file no later pull removed the old attribution value. The record now stays until the file can be read. * fix(docs): keep any non-file entry of the member's where the team deleted a doc (#993) A link at a removed team doc's path goes only when it is a version of that doc from the team history; any other entry that is not a regular file stays and is named. Adds the real-CLI case for a member's link replacing a doc. * fix(sync): never adopt or write through a link at a delivered file's path (#993) isTeamaiCopy and the copy judgement followed links: a link at a rule or agent path whose target held an older team version was taken as teamai's, and the next pull wrote through it into the member's file. A link in place of a delivered file is now the member's (kept, named), a link there is never removed as teamai's copy, and a skill directory holding a link is not teamai's copy, since copying over it would write through it. * fix(docs): keep a member's directory of links where the team deleted a doc file, and do not call it stale (#993) A directory that replaced a former team doc file and held only links read as teamai's (links are not listed), so the prune removed it. Such a directory is now the member's whenever it holds a link. doctor also stops listing files inside a directory pull keeps whole as stale docs. * fix(hooks): recognize unrecorded team hooks in every format, on pull and uninstall (#993) Pull recovered a team hook entry whose record was lost only in Claude and Codex files. In Cursor, Copilot and ZCode files the old entry stayed as the member's and the new one was added beside it, so both ran. Each entry is now compared with the team's hooks rendered the way that format's reconciler writes them; one equal to exactly one hook is replaced or removed as teamai's. Uninstall did not judge unrecorded entries at all, so with the records lost it left custom team hooks installed, and missed a main-checkout Codex file holding only team hooks. It now judges every hook file against the team's hook history, gated to the project as pull renders them in a non-self project scope, and removes the entries equal to exactly one team hook. * fix(sync): treat any non-regular entry in a skill or docs directory as the member's (#993) A directory is teamai's copy only when every entry is a regular file that is a team version; a link or any other non-regular entry anywhere below it makes it the member's. Adds real-CLI cases for a skill directory holding a link and a docs directory of links where the team deleted a doc file. * fix(sync): keep a member's link at any delivered path (#993) A link at a delivered skill directory, rule or agent file, docs entry or source skill destination is the member's: teamai never creates links, so it is never adopted, replaced, written through or deleted, on pull, remove and uninstall alike. Pull and doctor name it: "Kept <path>: it is a link of yours, so teamai does not replace it. Remove the link to receive <name> from the team." This deliberately changes earlier behavior: source and team skills used to replace a leaf link in place of a skill directory with a copy, and the Codex duplicate cleanup deleted a configured leaf link. The source tests that asserted those now expect the link kept. * fix(git-exclude): report an unreadable exclude file as such, and ask git a fixed number of times per checkout (#915) * fix(agents): prove a tombstoned agent copy teamai's in a team-defined tool too (#993) For a tool teamai renders no agents for, the tombstone cleanup had no proof to check and removed any file with the removed agent's name. It now removes it only when it is a team agent's own bytes from the history; a member's file of that name stays. * fix(rules): never read through, write through or delete a member's link at a rule path (#993) The cleanup and rerender paths judged a rule copy by reading the file a link points at, so a link whose target held a team render was rewritten through or deleted: the "Already synced" rerender, the legacy rules directories (pull and uninstall), the reserved teamai-context name, the unselected-rule sweep, the moved nested copy, the flat OMP and Kiro copies, the older .md layout, and teamai remove rules. Each now treats a link as the member's and leaves it. The empty-directory sweep no longer deletes a directory whose only entries are links. * fix(agents): keep a member's link where an agent was delivered, on pull and teamai remove (#993) The inactive-namespace cleanup judged a copy by the file a link points at, and teamai remove agents deleted the author's root copy by its placement record alone. A link at either path is now the member's and stays. * fix(skills): keep a skill directory that holds a member's link, on pull, remove and uninstall (#993) A recorded skill whose file the member replaced with a link to the same bytes read as unchanged, so pull copied over the link and remove, uninstall and the tombstone and inactive-namespace sweeps deleted the directory with every link in it. A skill directory holding a link is now the member's on pull, named by the link, and kept by every sweep. teamai remove skills also judges a link at a built-in skill's name before the name. * fix(sync): keep an unrecorded file teamai cannot read where a removed resource was delivered (#993) judgeRemoval counted a file it could not read as absent and removed it. Without its bytes nothing proves it teamai's, so it is now kept as the member's. * fix(docs): never read through or delete inside a member's link in a deactivated docs namespace (#993) Withdrawing a namespace's docs read each copy through any link on its path and deleted what matched the team file. A namespace directory the member linked to a folder of their own lost its files, and a linked doc was deleted. A path through a link is now left alone. * fix(source): keep a source skill copy that holds a member's link, on pull and source remove (#993) A recorded destination was the source's whole, so pull copied over a link the member put inside it and source remove deleted the directory with it. Such a copy is now the member's: pull names the link and source remove keeps the directory on record. * fix(local-agent): keep a member's link where the local agent installs a skill or rule (#993) The local agent installs through pullItem with no delivery record, which skipped every ownership check: a rule was written through a link and a skill directory link was replaced. A link at the path, or inside the skill directory, is now kept and named. * fix(sync): deliver team and source skills without the links in them (#993) Copying a skill recreated each link in its source as a link in the member's tool directory, which then read as the member's own, and let a skill plant a link to any target. Delivery now skips a link in a team or source skill, without following it, and names each one once: "Skipped <link> in <skill>: teamai does not deliver links." * fix(docs): keep a member's link in the docs mirror at a path the team never had (#993) This deliberately changes the mirror prune: it used to unlink any link at a path the team repo's history never had, as a local leftover. teamai never creates a link there, so such a link is the member's: pull now keeps it and names it, and doctor no longer reports it as a stale doc. A link matching a team link once at a removed doc's path is still pruned. The docs and doctor tests that expected the unlink now expect it kept. * feat(git-exclude): doctor checks the delivered blocks, pull --dry-run previews them, background pulls keep what they could not say (#915) * docs(designs): state that teamai never follows, replaces or deletes a member's link (#993) * fix(uninstall): remove only teamai's docs from the docs mirror, and keep the member's (#993) Uninstall deleted the whole docs directory, so a file pull had kept as the member's, a directory or a link of theirs went with it. It now removes a file or link only when it is a version of that doc from the team repo's history, names everything else, and removes a directory only once it is empty. While the history cannot be read, the mirror stays whole. A mirror inside the data home that keeps something survives the removal of that home with it. * feat(removals): never delete a file git tracks, in layout migrations and explicit commands (#915) Layout migrations (a rule's legacy .md and superseded copy, legacy rule directories, moved nested copies, agent format siblings, the Codex configured-copy dedupe, leftover files of another skill version) keep a tracked old copy and say where the resource lives now. teamai remove, source remove and uninstall remove the rest and name each tracked path; uninstall's summary lists them under Kept (tracked). A team repo with no rules left now runs the rules sweep, so the copies of a last rule the team deleted go, on the same ownership proof. Test names and comments describe behaviour instead of how the work was split. * test(commands-reference): load the command table in a fresh module registry (#915) The test now imports the CLI entry after vi.resetModules() and restores the env stub after it, so it reads the table under its own guard whatever another file in the same worker loaded or stubbed. * fix(mcp): write a linked MCP config through its link, and keep its target out of git (#993) A member's MCP config can be a symlink into a dotfiles repository. The atomic writes replaced the link with a regular file. They now write at the file the link points to, rollbacks included, and a linked CodeBuddy ~/.codebuddy/mcp.json is kept rather than deleted. A resolved value then lands in the link's target, so the git checks judge that file in its own repository: a target that repository tracks gets no value, and an untracked one is listed in that repository's info/exclude, which pull and uninstall now find to take the line out again. * fix(docs): name a kept link at a removed doc's path as before, and only a never-team path's link as such (#993) Goes with keeping a member's link at a path the team never had: a link at a removed doc's path keeps the line that says the team removed it. * test(skills): expect pull to name the member's link inside a skill directory (#993) * feat(git-exclude): doctor notes the copies pull keeps because the repository tracks them; document the checks, the preview and background pulls (#915) * fix(opencode): keep a member's linked opencode.json and openclaw.json as links (#993) The OpenCode instructions writer and the OpenClaw hook entry writer replaced a symlinked config with a regular file, and uninstall deleted a linked .opencode/opencode.json left holding only its $schema. Both now write at the link's target, and a link is never deleted. * fix(rules): never delete a linked CLAUDE.md or AGENTS.md when its teamai section goes (#993) Removing teamai's section from an instruction file teamai created deleted the file once nothing else was left, a member's link to it included. A link stays; its target is emptied instead. * fix(skills): push no links to the team repo, and name why remove and uninstall keep a skill directory (#993) A push copied a link inside the member's skill into the team repo, so every member then received a skill teamai will not deliver; push now skips each link and names it. remove and uninstall now say a kept skill directory is a link of the member's, or holds one, instead of claiming it has no delivery record. * fix(git-exclude): report a delivered path a rule re-includes as a failed sync (#915) * fix(source): keep reading a record into a removed OpenClaw workspace, and never another repository's history (#993) A source record of a copy in an OpenClaw workspace outside ~/.openclaw was rejected once that workspace was gone, so every pull and source remove of that source failed. The configured workspace now counts as a tool home even when its directory is missing. A source cache without its own .git no longer lets git read an enclosing repository's history (a dotfiles HOME): only today's source proves a copy then. * feat(git-exclude): a checkout on the un-migrated layout does not decide the setting with its own gitExcludeEnabled (#915) * feat(removals): keep tracked files in the built-in skill prune, and cover every tracked-copy guard through the real CLI (#915) * fix(source): leave a copy in a tool home that moved, instead of rejecting the record (#993) Configuring OpenClaw to another workspace made the record of a copy in the old one invalid, so every pull and source remove of that source failed. The record now notes the tool home of each absolute destination; one whose home moved is dropped from the record and its copy left as it is, while an absolute path the record cannot account for is still rejected. * fix(docs): keep the member's hidden files on uninstall, and match a link only to a team link (#993) uninstall did not count hidden entries in the docs mirror as kept, so with nothing else kept it deleted the data home around them. They are now named and keep the mirror. A link in the mirror is now teamai's only when the team history had a link with that target there, not a file holding the same text (the history now carries each version's git mode). * feat(uninstall): remove every teamai git exclude block after the files it hid; --agent drops only that tool's lines (#915) Uninstall reads the exclude files the project's delivered owners recorded, plus the project's own and the MCP ones, before it deletes anything, and under the partition's sync lock removes every teamai block there once the files are gone and before the partition state goes. Another project's delivered block in a shared repository stays. An MCP line stays while a config in any checkout of the repository may hold a resolved value, the main checkout of a --separate-git-dir repo included; a credentials line stays while its file is there. A read-only exclude file is named with the lines to delete by hand; a repository that is gone is skipped. uninstall --agent drops the tool's paths from every checkout's list, keeping a path another tool in use reads, and syncs the blocks again. uninstall --dry-run lists the blocks by owner and file. HTTP mode: a skill the local agent installed under its SKILL.md name (dir_name in its manifest) is now removed too. * fix(git-exclude): keep a recorded block when a path cannot be placed (#915) When git cannot say which repository a path lands in (sync reports it in gitFailed), its line may sit in any exclude file the owner recorded. A replace in that run emptied such a block and dropped the file from the owner's record, removing protection while reporting the failure. While any path fails placement, sync now only adds: every block keeps its lines, every recorded file stays recorded, and the paths that placed are still listed. The next sync in which every path places replaces as before. * fix(git-exclude): treat an unverified exclusion as a failure (#915) After writing a path's line, ensure counted every answer of `git check-ignore` except "would commit" as excluded. A check that timed out or failed in any other way therefore let the MCP reconcile write a resolved value into a file git was never shown to ignore. Only a verified "ignored" now counts as excluded; any other answer is gitFailed, naming git's error and the check to repair. The exclude file stays recorded, as its line was written. ensureExcludedFromGit maps this to its failed result, so the value is withheld. Two MCP exclude tests asserted the old reading and now expect the failure: git failing check-ignore while ls-files answers, and a path beyond a dangling directory symlink, which git refuses to check. * fix(skills): keep a delivered skill directory that holds a file the member added (#993) A skill directory with any file on record was taken as teamai's whole, so remove, uninstall and the tombstone cleanup deleted a file the member had added beside teamai's. A recorded directory now goes only when every file in it is on record or a team version; otherwise it is kept whole and named. Source skill removal applies the same rule against the source's files and history. * docs(mcp): a resolved value waits for git to confirm the exclusion (#915) * fix(skills): remove and uninstall take only teamai's files from a skill directory (#993) When a delivered skill directory holds a file the member added, remove and uninstall now delete only the files on record or matching a team version, name each file they keep, and leave the directory while anything is left in it. uninstall plans the exact files, so its dry run shows them. * fix(source): keep a member's file at the path of a link the source skill has (#993) Delivery never copies a link from a source skill, but removal counted any history at that path as the source's, the link included, so a regular file the member put there was deleted with the skill. Only a regular file the source has, or had, is the source's now. * fix(mcp): find teamai's CodeBuddy servers in every user MCP file, and treat a linked lookup path as its target (#993) A server an earlier teamai left in a CodeBuddy user MCP file that CodeBuddy no longer reads stayed there once its record was lost: pull and uninstall looked only at the file CodeBuddy reads, and returned early when that file was empty. They now also look at the other lookup files that exist and remove an unrecorded entry equal to a current or historical team render; a member's entry stays. Lookup files are compared by real path. With ~/.codebuddy/.mcp.json a symlink to ~/.codebuddy/mcp.json, a pull recorded the server at the link and then removed it from its target, the same file. With ~/.codebuddy.json a symlink to ~/.codebuddy/mcp.json, the upgrade move emptied and deleted the file the link points to. * fix(git-exclude): name the check to run when git cannot confirm an exclusion (#915) When git cannot confirm that it ignores a file after teamai lists it, the value stays withheld. The message now quotes git's error and names the next step: check that `git check-ignore -v <path>` works in that repository, then run the command again. The two MCP exclude tests changed in the previous fix asserted that an unconfirmed exclusion still let the resolved value be written, which is the behaviour fixed there; one of them now asserts the full warning. * fix(git-exclude): keep both delivered blocks when a path's repository cannot be located (#915) Pull splits delivered paths between the project's `delivered` block and `delivered/<id>` by the repository git names for each. A path whose repository git could not locate (a tool folder whose `.git` points to a deleted git directory) went to `delivered/<id>` only, so the `delivered` sync replaced its block and dropped the path's line, although git still reads that line from the project's own exclude file. Such a path now goes to both syncs. Each reports it as not placed and keeps every line its block holds; pull warns once per path. * fix(rules): keep a tracked teamai-context rule copy where nothing rewrites that path (#915) An earlier release's copy of a team rule named teamai-context is reclaimed at the tool's instruction file, which the instruction sync rewrites right after (Claude, Cursor, CodeBuddy, WorkBuddy). For OpenCode's .opencode/rules and Kiro's steering directory nothing rewrites it, so a copy the business repository tracks is now kept and named, as every other removal keeps one. * fix(uninstall): name a built-in skill file kept only because it is tracked once, without the member-files warning (#915) When removeOwnedFiles keeps a packaged file only because the repository tracks it, keepsTrackedCopy already names it. Uninstall no longer adds "holds files TeamAI did not put there. The packaged files were removed" for that directory, as pruneLegacyBuiltinSkills and retireOtherCodexCopy already do. * feat(mcp): MCP cleanup and the pull-time release reach every checkout of the project (#915) projectWorktreeConfigs and the pull-time MCP git exclude release now enumerate checkouts with projectCheckouts (live recorded checkouts plus a complete git worktree list, every recorded root when git cannot list them all), moved next to liveCheckoutRecords. From a linked worktree of a --separate-git-dir repo or a submodule, uninstall now removes teamai's servers and their resolved values from the main checkout's MCP config too, and drops the line once it is clean. A pull there no longer releases the shared .mcp.json line while the main checkout's config still holds a resolved value; that checkout's own pull cleans it and releases the line. Also covers uninstall --agent codebuddy and --agent workbuddy keeping the .codebuddy/rules lines the other tool still reads. * fix(sync): prove a CONTRIBUTORS file like any other, and a file only by a file the team had (#993) A skill's CONTRIBUTORS file counted as teamai's by its name alone, so remove and uninstall deleted the member's own one, and it never made a directory the member's. It now needs the same proof as any file. A link in the team history no longer proves a regular file holding the same bytes, and a version that changed only its mode is kept as its own version. * fix(skills): keep a delivered skill when the team or the source adds a file where the member has one (#993) A file the member added to a recorded team or source skill was overwritten once the team, or the source, delivered a file at that path. Pull now keeps such a copy whole and names it, as an edited one, unless the file is a version from the history. A source removal keeps a copy holding a file of the member's only when the source's own files prove the copy came from it: a copy from a replaced repository, or one an in-repo alias placed, is left to the record, as before. * fix(docs): keep a file of the member's at a path the team never had (#993) The docs mirror pruned any local file at a path the team history never had, so a member's own notes there were deleted on the next pull. Having no team version is no proof that teamai put a file there: the prune now removes only teamai's copies of docs the team removed, and leaves the member's own files without a word. doctor stops calling them stale, and the usage guide no longer says local drafts are removed. Earlier tests that expected such files pruned now use team docs the team removed. * docs(designs): the docs mirror prunes only teamai's copies of removed docs (#993) * fix(git-exclude): keep the blocks when the team setting cannot be read (#915) With the team's teamai.yaml missing or invalid and no member override, sharing.gitExclude fell back to off, so pull removed the delivered blocks and git add -A staged every delivered resource. In git mode that case is now unknown: pull and its preview leave the blocks as they are and report a failed sync (kept by a background pull), and doctor fails with the same message and names the source "team config unreadable". A member override still decides, and HTTP mode, which has no team setting, is unchanged. * feat(local-agent): keep its project skills and rules out of git, and never overwrite a member's file (#915) The skills and rules the HTTP local agent installs in a project now go in a local-agent block of the exclude file of the repository each copy lands in, while that project's git exclude flag is on (the project's config.yaml, else ~/.teamai/config.yaml). The agent syncs the block after each batch of project installs and uninstalls, lists only workspaces that are live checkouts, and records the exclude files it wrote in ~/.teamai/local-agent/git-exclude.json. Manifest entries record the tools they were written to; an entry from an older CLI counts as recorded where its copy equals the installed version or sits under its dir_name, and gains the field on its next install. A skill or rule destination the manifest does not record is the member's: the install is not done there and fails with "Kept <path>: it is not teamai's (not in the local agent's records). Rename or delete it; the local agent installs <slug> on its next sync." A copy equal to the download is taken over. source remove-http drops the block after uninstalling each resource, also when one fails, and teamai uninstall removes it from every exclude file the agent recorded, another repository's included. The instruction targets read the same per-workspace flag. The HTTP test backend gains rule downloads, the get-config, plugins/config and projects/mine routes, and scope/workspace_path on every command. * fix(rules): remove a rule copy that holds what pull writes today (#993) `remove rules` deleted a tool's copy only on the checkout's record or the team history. A checkout without history and without a record, such as a resource cache, left teamai's own copy behind. The copy that holds today's render of the team rule, or the rule verbatim, is now teamai's too, read before the team file goes, as `remove skills` already does. * fix(source): keep a source skill copy holding the member's file, on record (#993) A copy pull kept whole because the source added a file where the member had one was left off `installedSkills`, so `source remove` forgot it and dropped the manifest. It stays on record now. Removal then tells the source's files from the member's by their bytes, not their path, and a file at a path the skill never had keeps the directory even when no file of the source is left in it. * test(local-agent): uninstall_rule removes the installed copy and keeps the member's (#993) * feat(local-agent): keep model API keys in .codebuddy/models.json out of git, whatever the flag (#915) Before the local agent writes a model API key into a project's .codebuddy/models.json, it lists the file in the `credentials` block of the repository's .git/info/exclude and writes the key only once git confirms it ignores the file. A tracked file, one a .gitignore rule re-includes, an exclude file it cannot write, a workspace outside a git repository or an exclusion git cannot confirm leaves the file as it was; the task fails with the reason and the fix. The block's exclude files are recorded in ~/.teamai/local-agent/git-exclude.json. teamai no longer creates .codebuddy/.gitignore, and deletes the one it created while it holds only its two lines. An apply_model_config with no models, and `source remove-http`, delete a models file left with nothing (never a tracked one or a link), then its line. `teamai uninstall` keeps a credentials line, with a warning, while the models file still holds a key in any checkout of the repository. * fix(local-agent): write the model key outside a repository, and delete only models files teamai created (#915) A workspace outside any git repository has nothing that could commit .codebuddy/models.json, so the key is written there as before, with no git exclude line and no record of one. An empty model config, or removing the HTTP source, deletes the project models file only when teamai created it, recorded in the model manifest in the local agent's state home from now on. A file teamai did not create stays, and so does its line. * feat(local-agent): follow each project's git exclude option at every session start (#915) The local agent's `local-agent` block was only rebuilt after a batch that installed or removed a project skill or rule, so turning the option on or off in HTTP mode took effect only when the backend happened to send one. - Rebuild the block from the manifest and each workspace's option at every session start, and after project installs and uninstalls. The agent's other runs (prompt, tool call, stop) rebuild it only when the manifest or a config.yaml the option is read from changed since the last rebuild, judged by a hash kept in the local agent's state home, so they cost no git call (about 8 ms per prompt in a one-project measurement, against about 30 ms for an unconditional rebuild). - Keep a failed rebuild in the local agent's state home, under its own owner key, so a pull's success never clears it and the reverse. The next interactive pull prints it once, and doctor fails a check until a rebuild succeeds. Paths no line can name are kept as notices. - A workspace following a git-mode config whose team teamai.yaml cannot be read (and no gitExcludeEnabled) keeps the lines it has, and the agent records why; it installs and removes nothing there meanwhile. * fix(mcp): tell another tool's server by the file's real path (#993) An entry another tool's record claims in the same file is left to that tool. The file was compared by name, so a tool whose MCP path links to another's file adopted that tool's server once its own record was lost, and a later removal for it would take the server from the shared file. It is compared by real path now, as the rest of MCP reconciliation does. * fix(source): keep a source skill copy whose every file the member edited (#993) Removal kept a copy holding an edited file only when another file still matched the source. A copy whose every file the member edited, such as a single SKILL.md, was deleted by `source remove` or by a pull after the source withdrew the skill. When the source history has the skill, a file that matches no source version keeps the copy. * fix(local-agent): say what a refused install or uninstall left undone, and how to fix it (#915) While a project's flag cannot be resolved, the refusal now names the resource, the project and the unreadable file, says nothing was written, and gives both ways out: fix or restore the team's teamai.yaml, or set gitExcludeEnabled in the project's config.yaml. * fix(ownership): keep a member's entry of the other type at a delivered path (#993) A file where a team skill directory goes, or a directory where a rule or agent file goes, was not judged the member's. Delivery over it failed, yet the pull could record the team revision as synced, so once the member removed the entry a plain pull delivered nothing until the team changed or the member ran `pull --force`. Such an entry is now kept and named, and the next pull after it is gone delivers the team version. * feat(pull): keep shared config files that hold only teamai's entries out of git (#915) A project MCP config without a per-member place (.cursor/mcp.json, .github/mcp.json, .codex/config.toml, Kiro, OMP, Pi, .workbuddy/mcp.json, OpenCode's root opencode.json), .codex/hooks.json and OpenCode's .opencode/opencode.json are listed in the delivered block while git does not track them and they hold nothing but teamai's entries: no other top-level key ($schema included), and every server, team hook or instructions entry one teamai's records own (equal to what teamai writes, for the OpenCode instructions). They are judged after the MCP and hook reconciles, so a file written before the upgrade or after its records were lost qualifies by content. They are judged on every pull, fast path included, and replace their previous list (DeliveryRecorder.judgedAll). The first pull that finds an entry teamai does not own in a listed file takes its line out and says "<path> now holds entries teamai does not own, so git can see it."; a background pull keeps it as a git exclude notice for the next interactive pull. The usage guide documents the files and the limit when a teammate commits the same path. * fix(skills): keep a member's file when removing a skill with a built-in's name (#993) `teamai remove skills <name>` deleted the whole directory when the name is one the CLI once shipped (such as team-wiki-codebase), so a file the member added beside it was lost. It now removes the files the team history proves teamai's and the files a release packaged there, as uninstall does, and keeps and names a directory left holding anything else. * fix(skills): keep a delivered skill when the team adds a file where the member has a directory (#993) When the team added a file to a delivered skill at a path where the member had a directory, or a directory where the member had a file, the copy failed inside pull and the pull still advanced to the team revision, leaving the skill stale. Such an entry is the member's now: the skill is kept whole and named, as when the team adds a file where the member has one. * feat(docs): keep the delivered docs mirror out of git, still searchable (#915) * fix(pull): say nothing when a shared MCP config with a resolved value takes in a member's server (#915) Such a file stays in the mcp-exclude block whatever it holds, so leaving the delivered block does not make it visible, and "git can see it" was wrong. Also cover a .codex/hooks.json written before the upgrade (no hook records) and a single-repo team's .codex/hooks.json, which keeps the built-in hooks and stays visible. * fix(local-agent): tell CodeBuddy's user MCP file by its real path (#993) With `~/.codebuddy/.mcp.json` linked to `mcp.json`, a record an older agent wrote without a file resolved to `mcp.json`, while the install target was `.mcp.json`. The record was dropped as another file's, so reinstalling the same server failed as "not managed by teamai". Records are matched to the target by real path now, as MCP reconciliation does. * fix(skills): deliver a skill around a member's entry of the other type, and retry failed copies (#993) A member's directory where the team adds a file to a delivered skill, or a file where it adds a directory, is the member's: it is kept and named, the files it covers stay undelivered, and the rest of the skill is delivered. The pull is not counted as synced, so the next one is a full sync that delivers those files once the entry is gone. A skill, rule or agent copy that fails with an error no longer counts as synced either: the revision stays, and the next pull retries it. * fix(source): keep a source skill copy when the source adds a directory where the member has a file (#993) A file of the member's where the source now has a directory was missed by the collision check, so the copy failed on the conflict instead of keeping the member's copy. An entry of the other type at a source path, or at one of its parents, is the member's now, also when the source cache has no history. * fix(uninstall): keep OpenCode's user plugin on a project uninstall (#993) The plugin in the user's OpenCode config now also carries the team rules and instructions for V2, for every project and the user scope. A project-scope uninstall removed it, which ended delivery for every other project. It keeps it now and names it, as it does for the other global adapters; a user-scope uninstall still removes it, and so does `teamai hooks remove`. * fix(pull): keep a shared config's line while git cannot say it tracks the file (#915) A teamai-only file git could not answer for was judged `unknown`, and its entry was replaced with nothing, so its line left the block silently. Only a confirmed answer replaces the entry now; until then the line stays, the pull fails naming the file and git's error (a background pull keeps the failure), and doctor reports the checkout git cannot answer for. * fix(skills): keep a skill directory holding a git repository of the member's (#993) The ownership check reads a skill directory's files the way delivery lists them, which skips `.git`. A recorded skill whose files matched was then deleted whole by `teamai remove`, `uninstall` or a source removal, taking a repository the member made in it, with its local commits. A directory holding a `.git` is never deleted whole now; only teamai's files in it go. * fix(local-agent): keep a CodeBuddy server copy the member edited when moving it (#993) When CodeBuddy's preferred user MCP file changed, reinstalling a server moved it there and deleted the copy in the old file without checking it. A copy the member edited since teamai installed it is kept now, and named. * feat(mcp): give Claude the team's project MCP servers in its local scope (#915) With sharing.gitExclude on, Claude's project servers go to Claude Code's local scope, ~/.claude.json under projects[<key>].mcpServers, instead of .mcp.json. The key is the one Claude Code files the checkout under: the main checkout's real path for it and every linked worktree; in a --separate-git-dir repository a linked worktree's key is the git directory, and in a linked worktree of a submodule its directory under .git/modules. Resolved tokens leave the working tree, and Claude loads the servers without the approval prompt. Ownership is shared by every checkout using the key (managed-local-mcp.json in the project's data home), and uninstall removes exactly teamai's entries. The next pull moves teamai's servers out of .mcp.json, recorded or equal to a render in the team's history; keeps the member's own servers, a teamai server the member changed (named), and the names CodeBuddy still claims there; and deletes the file when nothing else is left and git does not track it. Its MCP exclude line goes once no checkout holds a resolved value. Turning the option off moves the servers back. While tclaude, which reads .mcp.json, is installed and enabled, Claude keeps the file. * feat(opencode): deliver team MCP servers through the plugin on OpenCode V2 (#915) With sharing.gitExclude on, OpenCode V2 and teamai's plugin current, a pull writes the project's team MCP servers to .opencode/teamai-mcp.json, which the plugin reads at setup and adds through ctx.mcp.transform for that project only. The file is teamai's whole, listed in the delivered block, and written only after the mcp-exclude block holds it when it carries a resolved value. The pull then takes teamai's V1 entries (recorded or adopted) out of the root opencode.json and .opencode/opencode.json when git does not track them and they hold nothing else, and deletes a file left empty. A tracked or mixed file is left; doctor names it. The plugin is reconciled before the decision, so a plugin from an older build is replaced and the V1 entries go in the same pull. Without a current plugin, on V1, or with the option off, the V1 entries stay or come back and the V2 file goes. The OpenCode major version is asked once per run. The plugin's project walk-up also stops at a .opencode/ holding only teamai-mcp.json. * fix(local-agent): keep a CodeBuddy server's record until its move is written (#993) Moving a server to the user MCP file CodeBuddy now reads recorded it in the new file before writing it there. When that write failed, the record pointed at the new file while the server stayed in the old one, so uninstall left it installed. The old file's record is existing ownership now: it stays until the new file is written, as for a server already recorded in the target file. * fix(uninstall): record a project's OpenCode exclusion when its plugins are kept (#993) A project-scope `uninstall --agent opencode` keeps the user plugin directory, so with only a server-pushed agent-hook plugin there the plan was empty: it said "Nothing to uninstall" and never excluded OpenCode, whose global hooks stayed active for the project. OpenCode now takes the exclusion-only path, like the other tools with global channels, and the kept agent-hook plugins are named with the other retained adapters. * fix(skills): list a skill delivered around a member's entry file by file (#915) A skill delivered around a member's entry of the other type was listed in the delivered exclude block as its whole directory, which hid the member's entry from git status. Such a skill is now listed one file per line, in pull and in the --dry-run preview. * fix(pull): list a delivered skill's files, never its directory (#915) A skill was listed as its directory (`/<dir>/`), so a file the member added inside a delivered skill was hidden from `git status` and `git add -A`. Every writer that delivers skills (team, role, project, source and built-in skills, Codex's `.agents/skills`, `.github/skills`, the local agent's installs) now reports the files it wrote, and the git exclude module refuses a directory. In a skill, a tracked file gets no line and is reported while the skill's other delivered files keep theirs; a member's negation that re-includes one of them fails the sync naming the rule. Doctor and `pull --dry-run` report per file. An older directory line goes on the next full sync, which rewrites the block. * feat(hooks): run Codex team hooks from a dispatcher when the project's hooks.json is not teamai's alone (#915) While sharing.gitExclude is on and a project's .codex/hooks.json is tracked, holds a member's entry or another top-level key (always in single-repo mode, whose committed file holds the built-ins), teamai stops writing the team's Codex hooks into it. The pull that sees this takes teamai's entries, recorded or adopted, out of the file through its hook manifest and records the project's hooks in ~/.teamai/codex-team-hooks.json; ~/.codex/hooks.json gets one `teamai hook-dispatch <Event> --tool codex --team-hooks` entry per event, timed out at the largest team-hook timeout, before the built-ins. Once the file is gone or teamai's alone again, the next pull writes the hooks back; turning the setting off does too. The new --team-hooks option of the hidden hook-dispatch command resolves the project from the hook's cwd, evaluates matchers, applies each hook's timeout (stopping its process group), and passes on stdout and a blocking exit 2. It exits at once when no project dispatches. The entries name no project, so their Codex trust holds as worktrees come and go; teamai trusts them like the built-ins, in single-repo mode too. Uninstall drops the project and the entries no other project needs. * fix(uninstall): keep the local agent's lines for the workspaces it still serves (#915) A project `teamai uninstall` removed every `local-agent` block the agent recorded, so another project's installs, and a linked worktree's in the shared exclude file, showed in `git status` until a later session start. The agent stays after a project uninstall, so its blocks are now rebuilt from its records without that project's workspace: only this project's lines go. A user-scope uninstall, which removes the agent, still removes every block. * fix(docs): keep a member's doc that has the bytes of a team doc that is a link (#993) A regular file of the member's at the path of a team doc that is a link was taken for teamai's copy when its bytes matched the link's target, and the mirror replaced it with the link. Equal bytes now prove a copy only of a team file; such a doc is the member's unless the team history has that file. * fix(mcp): keep a resolved value's own line when only teamai's blocks ignore the file (#915) A project MCP config that git already ignored got no `mcp-exclude` line. Since the `delivered` block lists teamai-only configs, that ignore could be teamai's own: once the member added a server, turned the option off or ran `uninstall --agent`, the `delivered` line went, and the file holding the resolved value became visible to `git add -A`. An ignore from teamai's own blocks no longer counts as already ignored: the line is skipped only when the rule `git check-ignore -v` names is outside every `# [teamai:` block. The `credentials` owner already lists every path, ignored or not; a test now covers that too. * fix(mcp): judge each CodeBuddy lookup file with the tools that write it (#993) Cleaning CodeBuddy's other user MCP files reused the ownership check built for the file it reads, which knew of no other tool writing there. With `~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json`, Claude's copy of a team server matched CodeBuddy's render and was deleted through the link. Each file is now judged with the records of every tool whose MCP file it is. * fix(docs): judge a deactivated namespace's doc by its type as well as its bytes (#993) When a docs namespace stops being active here, its copies that are teamai's are removed. A member's regular file whose bytes equal the target of a team doc that is a link was taken for teamai's, through the link and through a history check that also matched link versions, and deleted. A file is teamai's only as a team file now, today's or one from the history, as on every other docs path that deletes or replaces (prune, copy, uninstall). * test(hooks): check a Codex dispatcher entry outlasts its slowest team hook (#915) Codex stops the dispatcher entry at its own timeout, so it has to cover every team hook it runs, counting Codex's 600 s default for a hook that names none. * fix(mcp): count another tool's servers in a shared file even when it is not detected (#993) Whose entries a config file holds was judged only with the tools detected on this run. With `~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json` and Claude no longer installed, a CodeBuddy pull took Claude's identically rendered server for its own and deleted it through the link, leaving Claude's record stale. Every tool that maps a file counts now, detected or not: detection decides where teamai writes, never what it may delete. Doctor judges the same way. * fix(mcp): restore a file two tools write through linked paths once, to its first state (#993) Configs written during an MCP sync are restored when ownership cannot be saved. Snapshots were keyed by path, so a file two tools reach through linked paths got one per path and the later one, taken after the first tool's write, was restored last: the server stayed without its record. One snapshot per real file now, taken before its first write. * fix(coauthor): leave a settings file that does not parse as it is (#993) Project scope writes the co-author setting to `.claude/settings.local.json`, the member's own file. One that did not parse was read as empty, so a pull replaced the whole file with teamai's `attribution` block and the member's settings were lost. A Claude or Cursor file that is not a JSON object is now left untouched and reported as skipped, and the setting is written on the first pull after the member repairs it. * fix(hooks): leave a hook settings file that does not parse as it is (#993) Every hook writer read its settings file as empty when it did not parse, so a pull or a session-start pass while the member was mid-edit of, say, their HOME `~/.claude/settings.json` replaced the whole file with teamai's hooks. The Claude, Cursor, Copilot, Codex and ZCode writers, and the agent-hook install and removal, now leave such a file byte-identical and say which file and why; nothing is recorded, and the first pull after the repair writes it. * fix(docs): never walk a docs mirror that is a link of the member's (#993) When `sharing.docs.localDir` itself was a link to a personal directory, uninstall walked it and deleted the files behind it that matched a team doc from the history, outside the mirror. A mirror root that is a link is now the member's on every path: pull copies, prunes and withdraws nothing through it and names it, doctor reports it, and uninstall leaves it and what it points at. * fix(uninstall): keep the record of hooks a settings file that does not parse still holds (#993) Uninstall left out a hook settings file that did not parse, then deleted the data directory holding the record of its hooks; once the member repaired the file, teamai's hooks ran there with no record left to remove them by. Such a file is now tried, named and left as it is, and the data directory stays, so uninstall run again after the repair removes them. `source remove-http` and uninstall keep the record of an HTTP agent hook they could not remove, and the local agent's directory with it, instead of clearing the whole manifest. Pull also names a docs mirror that is a link before counting or previewing its files, so it never walks it, on a dry run either. * fix(uninstall): report an uninstall that left hooks in place as incomplete (#993) When a hook settings file could not be cleaned, uninstall kept the data directory but still said "teamai uninstalled" and exited 0, and a targeted uninstall of the last tool did not record the tool as excluded, so the hook left in place could sync its resources back. Hooks left in place now make the uninstall incomplete, as an OpenCode entry it could not remove does: exit code 1, the files named, and the tool excluded. The message for a settings file that does not parse no longer points at `teamai pull`. * fix(uninstall): stop hooks an incomplete uninstall left in place from syncing back (#993) A full uninstall that could not remove a tool's hooks kept the data directory with every tool still enabled, so the hook left in place ran a session-start pull that restored what had just been removed. Each tool whose hooks remain is now excluded, and the session-start pull of an excluded tool's hook does nothing, as its instruction and HTTP handlers already did, until the uninstall is run again after the repair. * fix(local-agent): keep removed HTTP sources disabled (#993) * fix(local-agent): serialize HTTP sync and source removal (#993) * docs(http): describe skipped concurrent hook syncs (#993) * fix(mcp): take teamai's Claude local-scope servers out of every recorded key on uninstall and flag off (#915) Uninstall and a pull with sharing.gitExclude off took teamai's servers out of the current checkout's key only. A key no checkout has any more, as a removed linked worktree of a --separate-git-dir repository leaves, kept them, with their resolved values, for good. Both now clean every key recorded in managed-local-mcp.json; a server the member changed stays and is named, except on uninstall. * feat(mcp): give CodeBuddy the team's project MCP servers in its local scope (#915) With sharing.gitExclude on, CodeBuddy's project servers go to ${CODEBUDDY_CONFIG_DIR:-~}/.codebuddy.json under projects[<realpath of the worktree root>].mcpServers, the key CodeBuddy uses for the directory it runs in, instead of the project's .mcp.json. - Each checkout writes its own key, on pull and on the pull git runs when it creates a worktree; records live with Claude's local-scope records, keyed by tool and key. - The next pull takes teamai's servers out of the key of a worktree that is no longer a checkout of the repository, keeping the member's own and a copy they changed (named). - Migration and flag-off follow Claude's: teamai's servers leave .mcp.json (recorded or proven by the team's history), the file goes when only they made it, and turning the option off moves them back. - Uninstall removes exactly teamai's servers from every checkout's key. * fix(opencode): on V2, take teamai's V1 entries out of a file that also holds the member's (#915) On OpenCode V2 with teamai's plugin, a pull left the root opencode.json and .opencode/opencode.json as they were once they held anything of the member's, so V2 loaded teamai's servers twice. It now takes teamai's own servers (recorded, or adopted by the per-name judgement) and its instructions entries (equal to the ones it writes) out of a file git does not track, and keeps the member's. A file git tracks is still never modified, and doctor still names it. * fix(uninstall): remove the HTTP source under its lock before teardown (#993) A full user-scope uninstall removed agent hooks, plugins and ~/.teamai without the local-agent lock, so a hook sync paused after loading its config could reinstall resources once uninstall reported success. Uninstall now runs the remove-http shutdown first: it waits up to 30 seconds for the lock, disables the source before teardown, and exits 1 without removing anything when the lock cannot be taken. * fix(local-agent): let a server-pushed uninstall hold its sync's lock (#993) A sync holds the HTTP source lock while it runs an uninstall_teamai command, so the uninstall it spawns waited for that lock until the hook's 15-second budget killed the sync, unacknowledged and with teamai still installed. The sync now passes its pid to the command; a child whose parent holds the lock runs under it without acquiring or releasing it. * test: stop hooks-wrapper from writing src/index.js, which shadowed the CLI entry (#915) hooks-wrapper.test.ts wrote a stub src/index.js for the length of each test so resolveTeamaiEntryScript() would find an entry. While it existed, any test file running in parallel that imported '../index.js' resolved to the stub instead of src/index.ts and got an empty module, so `program` was undefined: a different command-table test failed on each loaded run. The test now reports src/index.js as present through an existsSync spy and writes nothing into the source tree. Tests that read the command table load it through helpers/command-table.ts, which fails with the reason when the entry yields no table instead of a TypeError. * refactor(mcp): judge local-scope claims on the reconcile's claim targets (#915) Leaving a tool's other MCP location re-resolved every tool's ta…
Summary
A project MCP config that holds a resolved token is kept out of git through the clone's own
.git/info/exclude. Nothing committed changes.Most of this landed with #880: it was stacked on
2033b5behere, and its squash (7c929a99) carries the exclude block, uninstall, doctor's check andmanaged-mcp-files.json. See #880 for that design. Againstmain, this PR is the follow-up fixes after2033b5be. They close three gaps:Type of Change
Test Plan
npx tsc --noEmitpassesnpm run lintpassesnpx vitest runpasses (352 files, 6145 passed, 1 skipped atd548eaa2;CLAUDE_CONFIG_DIRunset, see fix(tests): prevent model tests from overwriting Claude config (P1) #890)9c3333d9and pass after the fix.npm run test:e2e: 391 passed, 26 skippedReal CLI after the merge (
npm run build, temporaryHOME,CLAUDE_CONFIG_DIRunset, project scope,toolPathsputs Copilot and Claude on.mcp.json,jirafor Copilot withBearer ${JIRA_TOKEN},openfor Claude):Real CLI, a moved Copilot (same setup, pre-fix build
7ffd5593against17b1a59d): Copilot on.mcp.jsonwrites a barejirawith the token; the team moves Copilot to.github/mcp.jsonand givesjirato Claude as a literal; token unset, exclude emptied, pull.A second run of the base flow (Claude, project scope) also passed: pull, a second pull, doctor ✔, then
uninstall --forceremoved the token and the block. Real CLI, local agent (hook-dispatch session-startagainst a local fake HTTP backend;install_mcpwith a Bearer header, then the files left as an agent from before this PR left them: exclude emptied, nomanaged-mcp-files.json, noresolvednote; one more sync). Pre-fix build17b1a59dagainst9c3333d9:The local agent's failed-write case (a read-only directory) is covered by its unit test only.
Review verification at
4e71c7a6npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 260 passed. Covers an identical member entry through pull/removal and install/update/uninstall, legacy records without placement evidence, and a failed ownership write leaving the user config visible to Git.npx tsc --noEmit,npm run lint,npm run build: passed.npx vitest run: 352 files, 6087 passed, 1 skipped. The first run alongside other checks had six failures inpush-env.test.ts; its isolated run passed all 19, as did the same file on9c3333d9. Repeating the full suite without concurrent checks passed.copilot-mcp.test.tsandmcp-uninstall.test.ts: 2 passed. The earlier full e2e record above remains from9c3333d9; the full provider/agent suite was not rerun for this follow-up.hook-dispatch session-start --tool copilotagainst a fixture HTTP backend: install, update and uninstall preserve an identical member-owned barejira; a directory atmanaged-mcp.jsonmakes install fail, while the config and exclusion stay unchanged, no sidecar appears, andgit statusstill lists?? .github/mcp.json. Harness:/tmp/882-r6-cli.mjs.Review verification at
1a4ddfa14e71c7a6: pull update, dropping a server, removeAll, local-agent install/update, and local-agent uninstall. A sixth guard covers missing-secret retention. All six pass after the fix, including a repeated pull after a skipped update.npx vitest run: 352 files, 6093 passed, 1 skipped.npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 266 passed.npm run build,npx tsc --noEmit,npm run lint: passed.copilot-mcp.test.tsandmcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from9c3333d9.hook-dispatch session-start --tool copilotagainst a fixture HTTP backend: initial bare TeamAI install succeeds; the member then adds a same-named keyed server; two update commands fail withnot managed by teamaiand preserve both entries; uninstall succeeds and removes only TeamAI's bare copy. Harness:/tmp/882-r7-cli.mjs.git merge-tree --write-tree HEAD origin/main: no conflicts with128844fe. The existing branch commits all belong to [feat] keep project MCP configs with resolved tokens out of git #882.Review verification at
c0748a9b1a4ddfa1and pass after the fix: legacy bare records and a failed post-write manifest update, each with distinct or identical keyed member content, exercised through pull/update/remove and local-agent install/uninstall; plus compatibility for a legacy keyed record with a matching hash.npx vitest run: 352 files, 6106 passed, 1 skipped.npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 279 passed.copilot-mcp.test.tsandmcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from9c3333d9.npx tsc --noEmit,npm run lint,npm run build: passed.hook-dispatch session-start --tool copilotagainst a fixture HTTP backend: a legacy unmarked bare record and an injected failure on the second atomic ownership-manifest rename both reject repeated updates and preserve the member's keyed entry on uninstall. The failure case uses identical bare/keyed content. A genuine legacy keyed entry with a unique matching hash still updates, recordsbare: false, and uninstalls. Harness:/tmp/882-r8-cli.mjs, fault injection:/tmp/882-r8-fault.mjs.git merge-tree --write-tree HEAD origin/main: no conflicts with128844fe. Every branch commit belongs to [feat] keep project MCP configs with resolved tokens out of git #882.Latest review verification at
d548eaa2npx vitest run: 352 files, 6145 passed, 1 skipped. MCP targeted suite: 420 passed; final local-agent suite after snapshot simplification: 76 passed.npx tsc --noEmit,npm run lint,npm run build: passed.copilot-mcp.test.tsandmcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from9c3333d9.hook-dispatch session-start --tool copilot, isolated HOME/Git workspace, fixture HTTP backend. Tested a legacy bare update, a proven bare-to-keyed update and uninstall. For each, injected an atomic config-rename failure, then a post-config ownership-manifest rename failure. Both failures reported failed, preserved the exact prior config/ownership, and left the credential file absent from Git status. Retry and subsequent uninstall succeeded; the old token was removed and the member's keyed server survived. Harness:/tmp/882-r9-cli.mjs; rename-fault preload:/tmp/882-r9-fault.mjs.git merge-tree --write-tree HEAD origin/main: no conflicts with128844fe; no unrelated branch commits.Related Issues
Closes #882. Found while implementing #879 / #880.
Notes for Reviewers
Door: two-way. Only writes a marked block in a local, uncommitted file; uninstall removes it.
Blast Radius: MCP configs. HTTP-backed teams using local-agent install/uninstall, JSON/TOML reconcile failure recovery, and Copilot project configs shared with another tool.
${VAR}, so there is no definition to judge against. Only a bare stdio command is treated as credential-free.bare: true), completed keyed writes (bare: false), and unknown placement. An unknown record needs a matching hash in the keyed entry and no equal hash in the bare entry; an identical duplicate is ambiguous and left alone.mainused2033b5beas its base. One real conflict came out of it, inapplyJsonTarget: feat(env): team-declared secrets with member-local values (#875) #880'sholdsResolvedValueand this branch's bare-copy removal are both kept.Known limits
teamai uninstalltakes the line out: no pull orteamai mcp removereleases it. An older install's file is listed only in the current workspace; other workspaces are covered at their next session.mcpServers, every other top-level object reads as a bare server. An object-valued setting there reads as a server no record claims, which keeps the line.